希曼日记

用 prlctl 从 macOS 驱动 Parallels 里的 Windows 做桌面应用 e2e

约 6 分钟2,387 字

Scorpius 是一个用 Tauri 2 写的剪贴板工具,主要在 macOS 上开发。要发 Windows 版,就得回答两个问题:Windows 上的全局快捷键、焦点恢复、模拟粘贴这套原生逻辑到底能不能用;每次改完怎么不靠手工点一遍来验证。

手头只有一台 Mac 和 Parallels 里的 Windows 11。本文记录的方案不需要在虚拟机里装 WinAppDriver、Selenium 或任何 agent,只用 Parallels 自带的命令行工具 prlctl,加上共享目录和客户机自带的 PowerShell。最终 9 个用例全部通过,单次运行约 2 分钟。

适合谁看:需要在 macOS 上为 Windows 桌面应用做自动化验收,且已经有 Parallels 的开发者。文中代码来自项目仓库,可以直接照搬。

环境

项目值
宿主机Mac Studio,Apple Silicon,macOS 26
Parallels Desktop27.0,prlctl version 27.0.0
客户机Windows 11 ARM64(10.0.26200),已安装 Parallels Tools,用户已登录
被测应用Tauri 2 + Rust,交叉编译为 aarch64-pc-windows-msvc
测试运行器Node.js 26 自带的 node --test,无第三方测试框架

客户机是 ARM64,所以下文构建的也是 ARM64 版本。x64 版本需要另一台 x64 的 Windows,方法相同。

先摸清 prlctl 能做什么

正式写代码前,先用几条命令确认能力边界。

列出虚拟机与状态

Bash
prlctl list -a
# UUID STATUS IP_ADDR NAME
# {33665c77-...} running - Windows 11
prlctl status "Windows 11"

在客户机里执行命令

Bash
prlctl exec "Windows 11" cmd /c "echo %USERNAME%"
# LENS5BC6$

这里有第一个关键细节。不带参数的 prlctl exec 以 SYSTEM 身份在 session 0 运行,返回的用户名是机器账户 LENS5BC6$。这个会话没有交互桌面,看不到用户的窗口、剪贴板和全局快捷键,对 GUI 测试没有用。

加上 --current-user,命令就在已登录用户的交互会话中运行:

Bash · C:\Users\lens
prlctl exec "Windows 11" --current-user cmd /c "echo %USERNAME% && echo %USERPROFILE%"
# lens

后面所有和界面有关的操作都必须带这个参数。

截图

Bash
prlctl capture "Windows 11" --file /tmp/vm.png

capture 直接从宿主机截取虚拟机整个屏幕,不经过客户机,也不会改变客户机的前台窗口。这个特性在后面很重要。

文件传输

客户机里没有任何工具链,被测的 exe 需要从宿主机送进去。Parallels 默认开启「共享用户配置」(Shared Profile),客户机能看到 \\Mac\Home,但只暴露 Mac 家目录下的 Desktop、Documents、Downloads 三个目录,不是整个家目录:

PowerShell
Get-ChildItem '\\Mac\Home' | Select-Object Name
# Desktop
# Documents
# Downloads

一开始把文件放在 ~/scorpius-e2e,客户机报「找不到文件」;改到 ~/Downloads/scorpius-e2e 就能直接访问 \\Mac\Home\Downloads\scorpius-e2e。这个目录同时也是客户机把测试结果回传给宿主机的通道。

剪贴板

Bash
prlctl exec "Windows 11" --current-user powershell -NoProfile -Command "Set-Clipboard -Value 'probe'; Get-Clipboard"
# probe

客户机自带的 PowerShell 就能读写剪贴板,不需要额外工具。

到这里能力已经够用:执行、截图、传文件、设剪贴板,再加 PowerShell 里的 WinForms 和 Win32 API,就能覆盖桌面应用的核心流程。

交叉编译 Windows 版本

客户机没有 Rust 和 Node,在虚拟机里装完整工具链既慢又污染测试环境,所以选择在 macOS 上交叉编译。

一次性准备

Bash
rustup target add aarch64-pc-windows-msvc
cargo install cargo-xwin # 自动下载 Windows SDK 和 MSVC CRT
brew install llvm makensis # llvm-rc 编译资源文件,makensis 生成安装包

cargo-xwin 负责链接(用 Rust 自带的 rust-lld)和 SDK 头文件,不需要 Visual Studio。Tauri 的 tauri-winres 在非 Windows 宿主机上需要 llvm-rc 来编译图标和 manifest 资源,这是唯一必须从 Homebrew 装的工具。Homebrew 的 llvm 包已经不再附带 lld,不用管它。

构建脚本

JavaScript · scripts/win-build.mjs
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
const target = process.env.SCORPIUS_WIN_TARGET || "aarch64-pc-windows-msvc";
const llvm = "/opt/homebrew/opt/llvm/bin";
const env = { ...process.env, PATH: `${llvm}:${process.env.PATH}` };
if (!existsSync(`${llvm}/llvm-rc`)) {
console.error(`missing ${llvm}/llvm-rc: brew install llvm`);
process.exit(2);
}
const bundles = process.argv.includes("--nsis") ? ["--bundles", "nsis"] : ["--no-bundle"];
const r = spawnSync("pnpm", ["exec", "tauri", "build", "--runner", "cargo-xwin", "--target", target, ...bundles, "--ci"], {
env, stdio: "inherit",
});
process.exit(r.status ?? 1);

--runner cargo-xwin 让 Tauri CLI 用 cargo xwin build 代替 cargo build;--no-bundle 只出 exe,--bundles nsis 额外产出安装包。

一个依赖坑:ring 在 ARM64 交叉编译时失败

项目里有一段用 ureq 做 HTTPS 的代码,默认走 rustls,间接依赖 ring。构建报错:

Text
clang: error: no such file or directory: '/imsvc'
error: failed to run custom build command for `ring v0.17.14`

原因在 ring 的 build.rs:

Rust
// FIXME: On Windows AArch64 we currently must use Clang to compile C code
let compiler = if target.os == WINDOWS && target.arch == AARCH64 && !compiler.is_like_clang() {
let _ = c.compiler("clang");
c.get_compiler()
} else { compiler };

cargo-xwin 给的编译器是 clang-cl,cc 把它归为 MSVC 家族,is_like_clang() 返回 false,于是 ring 强行换成 GNU 风格的 clang;但环境变量里 CFLAGS_aarch64_pc_windows_msvc 仍是 /imsvc <路径> 这种 MSVC 写法,clang 把 /imsvc 当成文件名。

解法是不要 ring:把 ureq 切到 native-tls,Windows 用 Schannel,macOS 用 Security.framework,都是纯 Rust 绑定,不需要编 C:

TOML
ureq = { version = "2", default-features = false, features = ["native-tls", "json"] }
native-tls = "0.2"
Rust
fn http_agent() -> Result<ureq::Agent, String> {
let tls = native_tls::TlsConnector::new().map_err(|e| format!("TLS 初始化失败: {e}"))?;
Ok(ureq::AgentBuilder::new()
.timeout(HTTP_TIMEOUT)
.tls_connector(std::sync::Arc::new(tls))
.build())
}

cargo tree -i ring --target aarch64-pc-windows-msvc 确认依赖图里没有 ring 后,构建通过,产物约 5.9 MB。顺带的好处是应用改用系统证书库,不再打包一份根证书。

harness 设计

整个 harness 只有三个文件:

Text
tests/win-e2e/
vm.mjs # prlctl 的薄封装
Scenario.ps1 # 在客户机里跑的交互场景
scorpius.e2e.mjs # node --test 用例

vm.mjs:prlctl 封装

JavaScript
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, existsSync } from "node:fs";
export const VM = process.env.SCORPIUS_VM || "Windows 11";
const HOME = process.env.HOME;
// Shared Profile 只暴露 Desktop/Documents/Downloads,用 Downloads 做传输目录
export const HOST_DROP = `${HOME}/Downloads/scorpius-e2e`;
export const GUEST_DROP = `\\\\Mac\\Home\\Downloads\\scorpius-e2e`;
export function ensureDrop() { mkdirSync(HOST_DROP, { recursive: true }); }
export function prl(...args) {
return execFileSync("prlctl", args, { encoding: "utf8", maxBuffer: 1 << 24 });
}
/** 以已登录用户身份在客户机执行命令 */
export function exec(cmd, opts = {}) {
// Parallels Tools 在密集调用后偶尔返回 255 且无输出,是传输层问题,重试即可
for (let attempt = 0; ; attempt++) {
const r = spawnSync("prlctl", ["exec", VM, "--current-user", ...cmd], {
encoding: "utf8", timeout: opts.timeout ?? 60_000, maxBuffer: 1 << 24,
});
if (r.error) throw r.error;
if (r.status === 255 && !r.stdout && attempt < 3) { spawnSync("sleep", ["1"]); continue; }
if (r.status !== 0 && !opts.allowFail) {
throw new Error(`guest exec failed (${r.status}): ${cmd.join(" ")}\n${r.stderr}\n${r.stdout}`);
}
return r.stdout;
}
}
/** 在客户机跑一段 PowerShell,输出按 UTF-8 解码 */
export function ps(script, opts = {}) {
const wrapped = `[Console]::OutputEncoding=[Text.Encoding]::UTF8; ${script}`;
const b64 = Buffer.from(wrapped, "utf16le").toString("base64");
return exec(["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", b64], opts);
}
/** 后台启动,不等进程退出 */
export function launchDetached(exePath, args = []) {
const argList = args.map((a) => `'${a.replace(/'/g, "''")}'`).join(",");
ps(`Start-Process -FilePath '${exePath}' ${args.length ? `-ArgumentList ${argList}` : ""}`);
}
export function screenshot(file) { prl("capture", VM, "--file", file); return file; }
export function guestFileExists(path) { return ps(`if (Test-Path '${path}') { 'yes' } else { 'no' }`).trim() === "yes"; }
export function readGuestFile(path) { return ps(`Get-Content -Raw -Encoding UTF8 '${path}'`); }
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function waitFor(fn, { timeout = 15_000, interval = 500, label = "condition" } = {}) {
const t0 = Date.now();
let last;
while (Date.now() - t0 < timeout) {
try { last = fn(); if (last) return last; } catch (e) { last = e; }
await sleep(interval);
}
throw new Error(`timeout waiting for ${label}: ${last instanceof Error ? last.message : JSON.stringify(last)}`);
}
export function haveVm() {
try { return existsSync("/usr/local/bin/prlctl") && /running/.test(prl("status", VM)); }
catch { return false; }
}

三个细节:

  • PowerShell 用 -EncodedCommand。脚本经 UTF-16LE 转 base64 传入,彻底绕开 prlctl exec 和 cmd 两层引号转义,中文路径和单引号都不用担心。
  • 输出强制 UTF-8。中文 Windows 的控制台默认 GBK,不设 OutputEncoding 拿到的是乱码。
  • 对 255 重试。连续调用十几次 prlctl exec 后,偶尔会得到退出码 255 且无输出,重跑同一条命令就正常。这是传输层抖动,不是被测应用的问题,封装层直接吞掉。

用例:非交互部分

启动、剪贴板捕获、持久化这类用例不涉及前台焦点,直接在宿主机用 vm.ps() 一步步驱动就行。

JavaScript
const EXE = process.env.SCORPIUS_WIN_EXE || "src-tauri/target/aarch64-pc-windows-msvc/release/scorpius.exe";
const GUEST_EXE = `${vm.GUEST_DROP}\\scorpius.exe`;
const STORE = `$env:APPDATA\\com.scorpius.clipboard\\scorpius.json`;
// 没有 VM 或没有构建产物时整组跳过,而不是失败
const skip = !vm.haveVm() || !existsSync(EXE);
const t = (name, fn) => test(name, { skip: skip && `needs running VM + ${EXE}` }, fn);
const readStore = () => JSON.parse(vm.readGuestFile(STORE.replace("$env:APPDATA", vm.ps("$env:APPDATA").trim())));
const killApp = () => vm.ps(`Get-Process scorpius -ErrorAction SilentlyContinue | Stop-Process -Force`, { allowFail: true });
const appRunning = () => vm.ps(`if (Get-Process scorpius -ErrorAction SilentlyContinue) {'yes'} else {'no'}`).trim() === "yes";
before(() => {
if (skip) return;
vm.ensureDrop();
copyFileSync(EXE, `${vm.HOST_DROP}/scorpius.exe`);
copyFileSync(new URL("./Scenario.ps1", import.meta.url), `${vm.HOST_DROP}/Scenario.ps1`);
killApp();
// 上一轮残留的窗口会污染焦点和 Z 序
vm.ps(`Get-Process notepad,Notepad -ErrorAction SilentlyContinue | Stop-Process -Force`, { allowFail: true });
// 清空数据文件,断言才是确定的
vm.ps(`Remove-Item -Force -ErrorAction SilentlyContinue "${STORE}"`, { allowFail: true });
});
t("2. clipboard text is captured into the store", async () => {
const marker = `e2e-text-${Date.now()}`;
vm.ps(`Set-Clipboard -Value '${marker}'`);
const store = await vm.waitFor(() => {
const s = readStore();
return s.history.some((i) => i.content.trim() === marker) ? s : null;
}, { label: "captured text in store" });
assert.equal(store.history.find((i) => i.content.trim() === marker).kind, "text");
});
t("4. clipboard image is captured as PNG data URL", async () => {
vm.ps(`
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
$bmp = New-Object System.Drawing.Bitmap 48, 32
$g = [System.Drawing.Graphics]::FromImage($bmp); $g.Clear([System.Drawing.Color]::Orange); $g.Dispose()
[System.Windows.Forms.Clipboard]::SetImage($bmp)
`);
const store = await vm.waitFor(() => {
const s = readStore();
return s.history.some((i) => i.kind === "image") ? s : null;
}, { label: "captured image", timeout: 20_000 });
assert.match(store.history.find((i) => i.kind === "image").content, /^data:image\/png;base64,/);
});

断言对象是应用自己的数据文件(%APPDATA%\com.scorpius.clipboard\scorpius.json),不是界面。文本、网址分类、图片入库都用这种方式验证,稳定且快。

用例:交互部分和两个坑

真正难的是这条链路:一个输入框有焦点 → 按 Ctrl+Shift+V → 面板出现并停靠在屏幕右侧 → 按 Enter → 内容粘贴回那个输入框 → 面板隐藏。它同时考验全局快捷键注册、GetForegroundWindow 捕获、SetForegroundWindow 恢复和模拟按键。

第一版和非交互用例一样,每一步都从宿主机 vm.ps() 一次,结果只有部分通过。截图显示面板其实出现了,但断言说窗口不可见;下一步的 Enter 打到了一个残留的记事本里。

在客户机脚本里加了一段每 100 ms 采样一次的跟踪,记录前台窗口标题和面板可见性,问题一目了然:

Text
0ms fg='Scorpius' panel=False
100ms fg='Scorpius' panel=False
...
800ms fg='Scorpius' panel=False
900ms fg='Windows Terminal' panel=False
1000ms fg='C:\Program Files\Parallels\Parallels Tools\Services\prl_tools_service.exe' panel=False

两个独立的问题叠在一起。

坑一:prlctl exec 每次调用都会抢走前台焦点。 900 ms 处宿主机开始轮询结果,每次 prlctl exec --current-user 都由 prl_tools_service.exe 在用户会话里拉起一个进程,那一瞬间它成为前台窗口。被测应用有「失去焦点自动隐藏」,面板就这样被测试自己关掉了;同时应用记录的「上一个前台窗口」也变成了这个辅助进程,粘贴目标随之错乱。

解法:整段交互序列放进客户机里的一个后台脚本,期间宿主机完全不调用 prlctl exec,结果通过共享目录写回来,宿主机只读本地文件。prlctl capture 不经过客户机进程,可以照常截图。

坑二:FindWindow 找到的是隐藏窗口。 前 900 ms 前台窗口标题明明是 Scorpius,IsWindowVisible(FindWindow(null, "Scorpius")) 却一直返回 false。原因是 Tauri 底层的 tao 会创建一个与主窗口同名的隐藏辅助窗口,FindWindow 恰好先命中它。改用 EnumWindows 枚举,只认可见的顶层窗口。

修正后的客户机脚本:

PowerShell · tests/win-e2e/Scenario.ps1
param(
[string]$Result = "$env:TEMP\scorpius-scenario.json",
[string]$Marker = "marker"
)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System; using System.Runtime.InteropServices; using System.Text;
public class W {
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
public delegate bool EnumProc(IntPtr h, IntPtr l);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);
// tao 会创建同名隐藏窗口,FindWindow 不可靠:只找可见的顶层窗口
public static IntPtr FindVisible(string title) {
IntPtr found = IntPtr.Zero;
EnumWindows((h, l) => {
if (!IsWindowVisible(h)) return true;
var sb = new StringBuilder(256); GetWindowText(h, sb, 256);
if (sb.ToString() == title) { found = h; return false; }
return true;
}, IntPtr.Zero);
return found;
}
}
"@
function FgTitle { $h=[W]::GetForegroundWindow(); $sb=New-Object Text.StringBuilder 256; [void][W]::GetWindowText($h,$sb,256); $sb.ToString() }
function PanelVisible { [W]::FindVisible('Scorpius') -ne [IntPtr]::Zero }
function PanelRect { $h=[W]::FindVisible('Scorpius'); $r=New-Object W+RECT; [void][W]::GetWindowRect($h,[ref]$r); @{left=$r.L;top=$r.T;right=$r.R;bottom=$r.B} }
$out = [ordered]@{ steps = @() }
function Step($name, $ok, $extra) { $script:out.steps += [ordered]@{ name=$name; ok=[bool]$ok; extra=$extra; fg=(FgTitle) } }
# 等启动这个脚本的控制台窗口消失,再碰前台
Start-Sleep -Milliseconds 1500
# 粘贴靶:一个 WinForms 文本框,比记事本可控得多
$form = New-Object System.Windows.Forms.Form
$form.Text = "Scorpius Paste Target"; $form.Size = New-Object System.Drawing.Size(520,320)
$form.StartPosition = "CenterScreen"; $form.TopMost = $true
$box = New-Object System.Windows.Forms.TextBox
$box.Multiline = $true; $box.Dock = "Fill"
$form.Controls.Add($box)
$form.Show(); $form.Activate(); $box.Focus()
[System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 800
[void][W]::SetForegroundWindow($form.Handle); [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 300
Step "target-focused" ((FgTitle) -eq "Scorpius Paste Target") $null
# 全局快捷键
[System.Windows.Forms.SendKeys]::SendWait('^+v')
$shown = $false; $trace = @()
for ($i=0; $i -lt 30; $i++) {
Start-Sleep -Milliseconds 100; [System.Windows.Forms.Application]::DoEvents()
$v = PanelVisible; $trace += "$($i*100)ms fg='$(FgTitle)' panel=$v"
if ($v) { $shown = $true }
if ($shown -and $i -ge 12) { break }
}
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
$rect = if ($shown) { PanelRect } else { $null }
# 通过共享目录通知宿主机「面板已打开」,并停留一会儿供 prlctl capture 取证
if ($shown) { Set-Content -Path ([IO.Path]::ChangeExtension($Result, ".panel-open")) -Value "1"; Start-Sleep -Milliseconds 2500 }
Step "panel-visible" (PanelVisible) @{ rect=$rect; screen=@{w=$screen.Width;h=$screen.Height}; trace=$trace }
# Enter 粘贴第一项
Start-Sleep -Milliseconds 400
[System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
$pasted = ""
for ($i=0; $i -lt 40; $i++) {
Start-Sleep -Milliseconds 150; [System.Windows.Forms.Application]::DoEvents()
$pasted = $box.Text
if ($pasted -like "*$Marker*") { break }
}
Step "pasted" ($pasted -like "*$Marker*") @{ text=$pasted; panelStillVisible=(PanelVisible) }
$form.Close()
$out | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 $Result

粘贴靶用 WinForms 自己画一个文本框,而不是开记事本:Windows 11 的记事本是标签页式富文本控件,读它的内容很麻烦;自己的文本框直接读 $box.Text,还能保证焦点确实在输入框里。

宿主机这边:

JavaScript
t("5+6. hotkey shows the panel docked right; Enter pastes into the focused textbox", async () => {
const marker = `e2e-paste-${Date.now()}`;
vm.ps(`Set-Clipboard -Value '${marker}'`);
await vm.waitFor(() => readStore().history[0]?.content.trim() === marker, { label: "marker on top of history" });
// 结果走共享目录:场景运行期间宿主机不能再调 prlctl exec
const hostResult = `${vm.HOST_DROP}/scenario-result.json`;
const guestResult = `${vm.GUEST_DROP}\\scenario-result.json`;
const flag = hostResult.replace(/\.json$/, ".panel-open");
rmSync(hostResult, { force: true }); rmSync(flag, { force: true });
vm.ps(`Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','${GUEST_SCENARIO}','-Marker','${marker}','-Result','${guestResult}'`);
// prlctl capture 不动客户机前台,场景中途截图是安全的
let shot = false;
const report = await vm.waitFor(() => {
if (!shot && existsSync(flag)) { shot = true; vm.screenshot(SHOT("05-panel-open")); }
if (!existsSync(hostResult)) return null;
const txt = readFileSync(hostResult, "utf8").replace(/^\uFEFF/, "");
return txt.trim().endsWith("}") ? JSON.parse(txt) : null;
}, { label: "scenario result", timeout: 40_000, interval: 250 });
const step = (n) => report.steps.find((s) => s.name === n);
assert.ok(step("target-focused").ok);
const panel = step("panel-visible");
assert.ok(panel.ok, `panel not visible\n${panel.extra.trace.join("\n")}`);
const { rect, screen } = panel.extra;
assert.ok(rect.right >= screen.w - 20, "panel should hug the right edge");
assert.ok(rect.bottom - rect.top > screen.h * 0.6, "vertical panel should span most of the screen height");
const pasted = step("pasted");
assert.ok(pasted.ok, `Enter did not paste. textbox=${JSON.stringify(pasted.extra.text)}`);
assert.equal(pasted.extra.panelStillVisible, false, "panel should hide after pasting");
});

宿主机读共享文件时去掉 BOM,并检查 JSON 是否写完整(Set-Content 不是原子的),否则会在文件写到一半时解析失败。

失败时断言信息带上完整的采样轨迹,下次再出问题不用猜。

用例:安装包

NSIS 安装包同样能静默验证,/S 是 NSIS 的静默参数,安装和卸载都认:

JavaScript
t("9. NSIS installer: silent install, launch from install dir, silent uninstall", async () => {
copyFileSync(SETUP, `${vm.HOST_DROP}/setup.exe`);
killApp();
const installDir = `${vm.ps("$env:LOCALAPPDATA").trim()}\\Scorpius`;
vm.ps(`Start-Process -Wait -FilePath '${vm.GUEST_DROP}\\setup.exe' -ArgumentList '/S'`, { timeout: 180_000 });
assert.ok(vm.guestFileExists(`${installDir}\\scorpius.exe`));
assert.ok(vm.guestFileExists(`${installDir}\\uninstall.exe`));
killApp();
vm.launchDetached(`${installDir}\\scorpius.exe`);
await vm.waitFor(appRunning, { label: "installed app running" });
assert.match(vm.ps(`(Get-Process scorpius).Path`).trim(), /\\Scorpius\\scorpius\.exe$/i);
killApp();
vm.ps(`Start-Process -Wait -FilePath '${installDir}\\uninstall.exe' -ArgumentList '/S'`, { timeout: 120_000 });
await vm.waitFor(() => !vm.guestFileExists(`${installDir}\\scorpius.exe`), { label: "uninstalled" });
});

Tauri 默认的 NSIS 安装模式是 per-user,安装到 %LOCALAPPDATA%\Scorpius,不需要管理员权限,静默安装不会弹 UAC。

运行结果

Bash
pnpm win:build # 交叉编译 exe
pnpm win:installer # 额外生成 NSIS 安装包
pnpm win:e2e # 需要 VM 正在运行且用户已登录
Text
✔ 1. app launches in the user session and stays resident (tray, no window) (9.5s)
✔ 2. clipboard text is captured into the store (5.3s)
✔ 3. URL is classified as kind=url (5.4s)
✔ 4. clipboard image is captured as PNG data URL (5.6s)
✔ 5+6. hotkey shows the panel docked right; Enter pastes into the focused textbox (19.8s)
✔ 7. store persists across restart (16.3s)
✔ 8. diagnostics file never contains clipboard content (7.8s)
✔ 9. NSIS installer: silent install, launch from install dir, silent uninstall (38.9s)
ℹ pass 8
ℹ fail 0

场景中途由 prlctl capture 取的证据截图,面板停靠在右侧、贴满高度,列表里是本轮测试写入的文本、图片和网址:

prlctl-e2e-panel-open

这套方案的边界

  • 只覆盖已登录的交互会话。 锁屏、注销状态下 --current-user 没有会话可用,测试会失败而不是跳过。
  • 每次 prlctl exec 约 1~3 秒。 大部分时间花在 Parallels Tools 的往返上,用例设计要减少调用次数,能在客户机一个脚本里做完的就不要拆成多步。
  • 前台焦点是共享资源。 任何涉及焦点的步骤都必须在客户机内连续完成,宿主机在这期间只能读共享文件和截图。
  • 架构绑定。 ARM64 客户机只能验证 ARM64 构建,x64 需要另一台机器。
  • 没有验证的东西要说清楚。 这套用例没有检查托盘图标外观(截图里它收在任务栏溢出区)、没有验证图片粘贴到第三方应用、用的是未签名的测试包。这些仍然需要人工确认。

命令速查

Bash
# 虚拟机
prlctl list -a # 列出所有 VM 和状态
prlctl status "Windows 11" # 单个 VM 状态
prlctl start|stop|suspend|resume "Windows 11"
# 在客户机执行(SYSTEM 会话,无桌面)
prlctl exec "Windows 11" cmd /c "ver"
# 在客户机执行(已登录用户的交互会话,GUI 测试用这个)
prlctl exec "Windows 11" --current-user powershell -NoProfile -Command "Get-Clipboard"
# 用 -EncodedCommand 传复杂脚本,避免引号转义
b64=$(printf '%s' 'Set-Clipboard -Value "hi"' | iconv -f UTF-8 -t UTF-16LE | base64)
prlctl exec "Windows 11" --current-user powershell -NoProfile -EncodedCommand "$b64"
# 截图(不影响客户机前台)
prlctl capture "Windows 11" --file /tmp/vm.png
# 文件传输:宿主机 ~/Downloads ↔ 客户机 \\Mac\Home\Downloads

客户机内常用的 PowerShell 片段:

PowerShell
Set-Clipboard -Value 'text' # 写文本到剪贴板
[System.Windows.Forms.Clipboard]::SetImage($bmp) # 写图片到剪贴板
[System.Windows.Forms.SendKeys]::SendWait('^+v') # 模拟 Ctrl+Shift+V
[System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
Get-Process scorpius | Stop-Process -Force # 结束进程
(Get-Process scorpius).Path # 进程可执行文件路径
Start-Process -Wait setup.exe -ArgumentList '/S' # NSIS 静默安装

小结

把 Parallels 当成一台能远程执行命令、能截屏、共享一个目录的 Windows 机器,就够做桌面应用的 e2e 了。真正花时间的不是工具,而是理解两件事:prlctl exec 的每次调用本身就是一次前台切换,交互步骤必须在客户机内一口气做完;框架创建的隐藏窗口会让 FindWindow 这种「显然正确」的 API 给出错误答案,要用可见性过滤。把这两点写进 harness 之后,剩下的就是普通的等待、断言和清理。