用 prlctl 从 macOS 驱动 Parallels 里的 Windows 做桌面应用 e2e
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 Desktop | 27.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 能做什么
正式写代码前,先用几条命令确认能力边界。
列出虚拟机与状态
prlctl list -a# UUID STATUS IP_ADDR NAME# {33665c77-...} running - Windows 11
prlctl status "Windows 11"在客户机里执行命令
prlctl exec "Windows 11" cmd /c "echo %USERNAME%"# LENS5BC6$这里有第一个关键细节。不带参数的 prlctl exec 以 SYSTEM 身份在 session 0 运行,返回的用户名是机器账户 LENS5BC6$。这个会话没有交互桌面,看不到用户的窗口、剪贴板和全局快捷键,对 GUI 测试没有用。
加上 --current-user,命令就在已登录用户的交互会话中运行:
prlctl exec "Windows 11" --current-user cmd /c "echo %USERNAME% && echo %USERPROFILE%"# lens后面所有和界面有关的操作都必须带这个参数。
截图
prlctl capture "Windows 11" --file /tmp/vm.pngcapture 直接从宿主机截取虚拟机整个屏幕,不经过客户机,也不会改变客户机的前台窗口。这个特性在后面很重要。
文件传输
客户机里没有任何工具链,被测的 exe 需要从宿主机送进去。Parallels 默认开启「共享用户配置」(Shared Profile),客户机能看到 \\Mac\Home,但只暴露 Mac 家目录下的 Desktop、Documents、Downloads 三个目录,不是整个家目录:
Get-ChildItem '\\Mac\Home' | Select-Object Name# Desktop# Documents# Downloads一开始把文件放在 ~/scorpius-e2e,客户机报「找不到文件」;改到 ~/Downloads/scorpius-e2e 就能直接访问 \\Mac\Home\Downloads\scorpius-e2e。这个目录同时也是客户机把测试结果回传给宿主机的通道。
剪贴板
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 上交叉编译。
一次性准备
rustup target add aarch64-pc-windows-msvccargo install cargo-xwin # 自动下载 Windows SDK 和 MSVC CRTbrew 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,不用管它。
构建脚本
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。构建报错:
clang: error: no such file or directory: '/imsvc'error: failed to run custom build command for `ring v0.17.14`原因在 ring 的 build.rs:
// FIXME: On Windows AArch64 we currently must use Clang to compile C codelet 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:
ureq = { version = "2", default-features = false, features = ["native-tls", "json"] }native-tls = "0.2"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 只有三个文件:
tests/win-e2e/ vm.mjs # prlctl 的薄封装 Scenario.ps1 # 在客户机里跑的交互场景 scorpius.e2e.mjs # node --test 用例vm.mjs:prlctl 封装
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() 一步步驱动就行。
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 采样一次的跟踪,记录前台窗口标题和面板可见性,问题一目了然:
0ms fg='Scorpius' panel=False100ms fg='Scorpius' panel=False...800ms fg='Scorpius' panel=False900ms fg='Windows Terminal' panel=False1000ms 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 枚举,只认可见的顶层窗口。
修正后的客户机脚本:
param( [string]$Result = "$env:TEMP\scorpius-scenario.json", [string]$Marker = "marker")Add-Type -AssemblyName System.Windows.FormsAdd-Type -AssemblyName System.DrawingAdd-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 300Step "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,还能保证焦点确实在输入框里。
宿主机这边:
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 的静默参数,安装和卸载都认:
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。
运行结果
pnpm win:build # 交叉编译 exepnpm win:installer # 额外生成 NSIS 安装包pnpm win:e2e # 需要 VM 正在运行且用户已登录✔ 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 取的证据截图,面板停靠在右侧、贴满高度,列表里是本轮测试写入的文本、图片和网址:
这套方案的边界
- 只覆盖已登录的交互会话。 锁屏、注销状态下
--current-user没有会话可用,测试会失败而不是跳过。 - 每次
prlctl exec约 1~3 秒。 大部分时间花在 Parallels Tools 的往返上,用例设计要减少调用次数,能在客户机一个脚本里做完的就不要拆成多步。 - 前台焦点是共享资源。 任何涉及焦点的步骤都必须在客户机内连续完成,宿主机在这期间只能读共享文件和截图。
- 架构绑定。 ARM64 客户机只能验证 ARM64 构建,x64 需要另一台机器。
- 没有验证的东西要说清楚。 这套用例没有检查托盘图标外观(截图里它收在任务栏溢出区)、没有验证图片粘贴到第三方应用、用的是未签名的测试包。这些仍然需要人工确认。
命令速查
# 虚拟机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 片段:
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 之后,剩下的就是普通的等待、断言和清理。
