mirror of
https://github.com/feigeCode/navop.git
synced 2026-11-06 08:07:31 +00:00
v0.20.1 的 Windows 产物(zip / portable / MSI 里装的是同一个 navop.exe)启动即失败:
「navop.exe - 无法找到入口 / 无法定位程序输入点 TaskDialogIndirect 于动态链接库
…\AppData\Local\Temp\navop-update\staged-<pid>-<ms>\navop.exe 上」。加载器在 `main`
之前就以 STATUS_ENTRYPOINT_NOT_FOUND(0xC0000139) 退出,所以更新表现成「什么都没发生」。
根因不是缺 comctl32.dll,而是产物**没有任何应用程序清单**:
- gpui 的 Windows 层在 `PlatformWindow::prompt` 中静态导入 `comctl32!TaskDialogIndirect`
(实测 v0.20.1 的 navop.exe:IMPORT 表里有该符号、DELAY_IMPORT 目录为空 ⇒ load-time 导入);
- 该导出只存在于 Common-Controls v6 的 SxS 程序集,只有 exe 自己的清单声明了
`Microsoft.Windows.Common-Controls 6.0.0.0` 才会激活 v6;没有清单就挂 System32 里的
5.82(不导出该符号)⇒ 加载器失败;
- 实测 v0.20.1 的 navop.exe 全文件搜 `Common-Controls` / `<assembly` /
`requestedExecutionLevel` 零命中 ⇒ 清单确实没被嵌进去。
清单的责任方在 v0.20.1 变了:`gpui-fast 0.1.8` 的 build.rs 是
`embed_resource_unless_host_provides_it()`,而 gpui-kit 声明了
`windows-manifest-provided-by-host`(`cargo tree -e features -p main -i gpui-fast` 可核)。
Cargo 特性只做并集、下游关不掉 ⇒ 这份清单只能由宿主 navop 自己提供。
v0.20.0 走旧门面 `gpui-pre`,它是无条件 embed 的,所以此前没暴露。
- `resources/windows/navop.manifest.xml`(新增):宿主清单,与上游 Zed 的
`crates/gpui/resources/windows/gpui.manifest.xml` 对齐(asInvoker + supportedOS Win10 +
Common-Controls 6.0.0.0 + `dpiAware true/pm` / `dpiAwareness PerMonitorV2` /
`heapType SegmentHeap`)。DPI 与堆这一段刻意与 v0.20.0 保持一致:gpui 两代都**不**在运行时
调 `SetProcessDpiAwareness`(全仓 grep 无命中),清单是 DPI 感知的唯一来源,漏掉会退回
DPI-unaware(高 DPI 下整窗被系统位图拉伸)。文件不带 XML 声明:winresource 是把清单按 RC
字符串逐行写成 `" <line> "`,声明前出现空白不是合法的 XML prolog。
- `main/build.rs`:加 `set_version_info(VersionInfo::FILETYPE, 1)` +
`set_manifest(include_str!("../resources/windows/navop.manifest.xml"))`。前者不是可选项 ——
winresource 只在 `VersionInfo::FILETYPE` 存在时才写清单块(`winresource-0.1.31/lib.rs:578`),
漏了会**静默不嵌清单**。用 `set_manifest` 而非 `set_manifest_file`,避开 rc 的路径转义。
- `script/validate-windows-exe.mjs`(新增):PE 层门禁,解析节表定位 `.rsrc` 并在其原始区间内查
三条必需串,另外判别「静态导入 TaskDialogIndirect 却无清单」这一真实故障组合;失败退出码 1
并打印含 `0xC0000139` 的修法。判据**必须同时搜 ASCII 与 UTF-16LE**:实测 llvm-rc 对纯 ASCII
清单写的是 8 位,MSVC rc.exe 才写 UTF-16,只搜一种编码必漏。
- `.github/workflows/release.yml`:Windows 腿在 `Build release binary (Windows)` 之后、
`Package (Windows)` 之前插入 `Validate Windows executable manifest`。这段代码在 macOS/Linux 上
根本不参与编译,而「清单没嵌进去」在编译期没有任何信号,只能在真实产物上校验。
- `script/test-release-packaging.mjs`:加合成 PE fixture(`peFixtureWithResource`)与两个用例
—— 断言清单内容、`main/build.rs` 的三处调用、workflow 步骤的存在与顺序;以及门禁对
正样本 / 负样本 / 非 PE 三种输入的退出码与报错内容。
验证(本机为 macOS,无法编译 Windows target):
- `node --test script/test-release-packaging.mjs` = 33 passed / 0 failed
- `cfg(target_os = "windows")` 块在 macOS 上不参与编译,`cargo check` 给不了信号。因此建探针
工程把 `main/build.rs` **逐字复制、只删掉那行 `#[cfg]`**,配
`[build-dependencies] winresource = "0.1"` ⇒ 该块**编译通过**(`set_version_info` /
`set_manifest` 的签名与 `winresource 0.1.31` 一致)。
- 取 winresource 真实生成的 `resource.rc` 手工喂 `llvm-rc`:退出码 0,`.res` 资源目录读出
`VERSION 1 / ICON 1..6 / GROUP_ICON 1 / RT_MANIFEST 1(1120 字节)`—— 清单落在加载器唯一认的
「type 24 / id 1」位置。
- 把该 `.res` 当 `.rsrc` 负荷塞进合成 PE:门禁判 OK;对**真实 v0.20.1 的 navop.exe** 判 FAIL
(退出码 1,报错含 `TaskDialogIndirect` 与 `0xC0000139`)。
- 未在本机验证:MSVC rc.exe / link.exe 真实产物能否正常启动、DPI 表现如何(需 Windows)。
CI 的 `windows-2022` 腿会编译这段代码。
1539 lines
62 KiB
JavaScript
1539 lines
62 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import { spawnSync } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import test from "node:test";
|
||
|
||
const read = (path) => fs.readFileSync(path, "utf8");
|
||
|
||
const workflowStep = (workflow, name) => {
|
||
const marker = ` - name: ${name}`;
|
||
const start = workflow.indexOf(marker);
|
||
assert.ok(start >= 0, `missing workflow step: ${name}`);
|
||
const end = workflow.indexOf("\n - name:", start + marker.length);
|
||
return workflow.slice(start, end >= 0 ? end : undefined);
|
||
};
|
||
|
||
// 合成一个最小 PE(单 `.rsrc` 节),给 `script/validate-windows-exe.mjs` 做正/负样本。
|
||
// 只保证节表能被门禁解析(MZ + PE 签名 + 节表 + 节数据),不追求可执行。
|
||
const peFixtureWithResource = (resourcePayload) => {
|
||
const peOffset = 0x40;
|
||
const sectionTable = peOffset + 24; // COFF 头 20 字节 + 可选的 PE32+ 头长度 0
|
||
const resourceOffset = sectionTable + 40;
|
||
const payload = Buffer.from(resourcePayload, "utf8");
|
||
const fixture = Buffer.alloc(resourceOffset + payload.length);
|
||
|
||
fixture.write("MZ", 0, "latin1");
|
||
fixture.writeUInt32LE(peOffset, 0x3c);
|
||
fixture.write("PE\0\0", peOffset, "latin1");
|
||
// COFF 头从 PE 签名之后开始(`peOffset + 4`):Machine / NumberOfSections / …
|
||
fixture.writeUInt16LE(0x8664, peOffset + 4);
|
||
fixture.writeUInt16LE(1, peOffset + 6);
|
||
fixture.writeUInt16LE(0, peOffset + 20);
|
||
fixture.write(".rsrc\0\0\0", sectionTable, "latin1");
|
||
fixture.writeUInt32LE(payload.length, sectionTable + 8);
|
||
fixture.writeUInt32LE(payload.length, sectionTable + 16);
|
||
fixture.writeUInt32LE(resourceOffset, sectionTable + 20);
|
||
payload.copy(fixture, resourceOffset);
|
||
return fixture;
|
||
};
|
||
|
||
test("release packaging uses the navop executable on every platform", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const bundle = read("script/bundle-macos.sh");
|
||
const plist = read("resources/macos/Info.plist");
|
||
const desktop = read("resources/linux/navop.desktop");
|
||
|
||
assert.doesNotMatch(release, /binary: onetcli(?:\.exe)?/);
|
||
assert.match(release, /navop\.exe/);
|
||
assert.match(bundle, /BINARY_NAME="navop"/);
|
||
assert.doesNotMatch(bundle, /generate-macos-icon\.sh/);
|
||
assert.match(bundle, /Error: Icon file not found/);
|
||
assert.match(plist, /<key>CFBundleExecutable<\/key>\s*<string>navop<\/string>/);
|
||
assert.match(desktop, /^Exec=navop %F$/m);
|
||
assert.match(desktop, /^Icon=navop$/m);
|
||
assert.match(desktop, /^StartupWMClass=navop$/m);
|
||
});
|
||
|
||
test("installers register database, Markdown, and terminal recording file associations", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const plist = read("resources/macos/Info.plist");
|
||
const desktop = read("resources/linux/navop.desktop");
|
||
const wix = read("installer/windows/navop.wxs");
|
||
const mimePath = "resources/linux/navop.xml";
|
||
|
||
assert.match(plist, /<key>CFBundleDocumentTypes<\/key>/);
|
||
for (const extension of ["db", "duckdb", "md", "cast"]) {
|
||
assert.match(plist, new RegExp(`<string>${extension}<\\/string>`));
|
||
assert.match(wix, new RegExp(`<Extension[^>]*Id="${extension}"`));
|
||
}
|
||
const macosRecordingDocument = plist.match(
|
||
/<dict>\s*<key>CFBundleTypeName<\/key>\s*<string>Terminal Recording<\/string>[\s\S]*?<\/dict>/,
|
||
)?.[0];
|
||
assert.ok(macosRecordingDocument, "missing macOS terminal recording document type");
|
||
assert.match(
|
||
macosRecordingDocument,
|
||
/<key>CFBundleTypeRole<\/key>\s*<string>Viewer<\/string>/,
|
||
);
|
||
assert.match(macosRecordingDocument, /<string>org\.asciinema\.cast<\/string>/);
|
||
assert.match(macosRecordingDocument, /<string>cast<\/string>/);
|
||
|
||
const macosRecordingUti = plist.match(
|
||
/<dict>\s*<key>UTTypeIdentifier<\/key>\s*<string>org\.asciinema\.cast<\/string>[\s\S]*?<\/dict>/,
|
||
)?.[0];
|
||
assert.ok(macosRecordingUti, "missing macOS terminal recording UTI");
|
||
assert.match(macosRecordingUti, /<string>public\.data<\/string>/);
|
||
assert.match(
|
||
macosRecordingUti,
|
||
/<key>public\.filename-extension<\/key>\s*<array><string>cast<\/string><\/array>/,
|
||
);
|
||
assert.match(
|
||
macosRecordingUti,
|
||
/<key>public\.mime-type<\/key>\s*<string>application\/x-asciicast<\/string>/,
|
||
);
|
||
|
||
const windowsRecordingProgId = wix.match(
|
||
/<ProgId[^>]*Id="Navop\.TerminalRecording"[\s\S]*?<\/ProgId>/,
|
||
)?.[0];
|
||
assert.ok(windowsRecordingProgId, "missing Windows terminal recording ProgId");
|
||
assert.match(
|
||
windowsRecordingProgId,
|
||
/<Extension[^>]*Id="cast"[^>]*ContentType="application\/x-asciicast"/,
|
||
);
|
||
assert.doesNotMatch(plist, /<string>(?:cast\.)?partial<\/string>/);
|
||
assert.doesNotMatch(wix, /<Extension[^>]*Id="(?:cast\.)?partial"/);
|
||
|
||
assert.match(
|
||
desktop,
|
||
/^MimeType=.*application\/vnd\.sqlite3;.*application\/x-duckdb;.*text\/markdown;.*application\/x-asciicast;/m,
|
||
);
|
||
assert.ok(fs.existsSync(mimePath), `${mimePath} must exist`);
|
||
const mime = read(mimePath);
|
||
assert.match(mime, /type="application\/vnd\.sqlite3"/);
|
||
assert.match(mime, /pattern="\*\.db"/);
|
||
assert.match(mime, /type="application\/x-duckdb"/);
|
||
assert.match(mime, /pattern="\*\.duckdb"/);
|
||
assert.match(mime, /type="text\/markdown"/);
|
||
assert.match(mime, /pattern="\*\.md"/);
|
||
assert.match(mime, /type="application\/x-asciicast"/);
|
||
assert.match(mime, /pattern="\*\.cast"/);
|
||
assert.match(mime, /pattern="\*\.cast\.partial"/);
|
||
assert.doesNotMatch(mime, /pattern="\*\.partial"/);
|
||
assert.match(release, /package\/usr\/share\/mime\/packages/);
|
||
assert.match(release, /resources\/linux\/navop\.xml/);
|
||
assert.match(release, /\/usr\/share\/mime\/packages\/navop\.xml/);
|
||
assert.match(release, /update-mime-database \/usr\/share\/mime/);
|
||
assert.match(release, /update-desktop-database \/usr\/share\/applications/);
|
||
});
|
||
|
||
test("renamed Linux packages replace legacy onetcli installations", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
|
||
assert.match(release, /Package: navop/);
|
||
assert.match(release, /Provides: onetcli/);
|
||
assert.match(release, /Replaces: onetcli/);
|
||
assert.match(release, /Conflicts: onetcli/);
|
||
assert.match(release, /Name: navop/);
|
||
assert.match(release, /Obsoletes: onetcli/);
|
||
});
|
||
|
||
test("Linux publishes one package per architecture plus a separate GPU dependency stack", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const installZig = workflowStep(release, "Install Zig toolchain (Linux)");
|
||
const build = workflowStep(release, "Build release binary");
|
||
const verifyBaseline = workflowStep(release, "Verify Linux glibc baseline");
|
||
const packageLinux = workflowStep(release, "Package (Linux)");
|
||
const packageGpuStack = workflowStep(
|
||
release,
|
||
"Package Linux GPU dependency stack",
|
||
);
|
||
const packageInstallers = workflowStep(
|
||
release,
|
||
"Package Linux installers (x86_64)",
|
||
);
|
||
|
||
// Linux ships exactly one build per architecture. The portable archive is an
|
||
// extra artifact produced from that very same binary inside the same job, not
|
||
// a second matrix entry; the private loader and its launcher are gone, and
|
||
// the Mesa stack travels as a separate dependency archive instead.
|
||
assert.doesNotMatch(release, /portable_linux/);
|
||
assert.doesNotMatch(release, /linux-x64-portable|linux-arm64-portable/);
|
||
assert.doesNotMatch(release, /package-linux-portable|linux-portable-launcher/);
|
||
assert.match(
|
||
release,
|
||
/linux_x64='\{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-latest"[^']*"archive":"navop-x86_64-unknown-linux-gnu\.tar\.gz"[^']*"public_label":"linux-x64"[^']*\}'/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/linux_arm64='\{"target":"aarch64-unknown-linux-gnu","os":"ubuntu-24\.04-arm"[^']*"archive":"navop-aarch64-unknown-linux-gnu\.tar\.gz"[^']*"public_label":"linux-arm64"[^']*\}'/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_arm64,\$windows_x64,\$windows_x86\]"/,
|
||
);
|
||
assert.match(release, /linux-x64\) matrix="\[\$linux_x64\]"/);
|
||
assert.match(release, /linux-arm64\) matrix="\[\$linux_arm64\]"/);
|
||
assert.match(release, /name: Build \(\$\{\{ matrix\.target \}\}\)$/m);
|
||
|
||
// Zig is what lowers the C runtime requirement to glibc 2.28, so it now runs
|
||
// for every Linux build rather than only for the retired portable variant.
|
||
assert.match(installZig, /if: runner\.os == 'Linux'/);
|
||
assert.match(installZig, /python3 -m venv "\$RUNNER_TEMP\/ziglang"/);
|
||
assert.match(installZig, /ziglang==0\.14\.1/);
|
||
assert.match(
|
||
installZig,
|
||
/cargo install --locked cargo-zigbuild --version 0\.23\.0/,
|
||
);
|
||
assert.match(
|
||
installZig,
|
||
/CARGO_ZIGBUILD_PYTHON_PATH=\$RUNNER_TEMP\/ziglang\/bin\/python/,
|
||
);
|
||
assert.match(installZig, /cargo-zigbuild --version/);
|
||
assert.doesNotMatch(installZig, /cargo zigbuild --version/);
|
||
assert.doesNotMatch(release, /Install portable packaging dependencies/);
|
||
assert.doesNotMatch(release, /musl-tools/);
|
||
|
||
assert.match(build, /if \[ "\$\{\{ runner\.os \}\}" = "Linux" \]/);
|
||
assert.match(
|
||
build,
|
||
/cargo zigbuild[\s\S]*--release[\s\S]*-p main[\s\S]*--target "\$\{\{ matrix\.target \}\}\.2\.28"/,
|
||
);
|
||
// No platform drops the default feature set: `embedded-webview` is off in the
|
||
// default set itself (see main/Cargo.toml), so the shape that gets published is
|
||
// the plain default build on all three platforms. The guard for that lives in
|
||
// its own test below.
|
||
assert.doesNotMatch(build, /--no-default-features/);
|
||
// The Touch Bar workaround feature is switched off again: upstream zed#65186 fixed the
|
||
// root cause (accesskit's plain Adapter instead of the dynamically subclassed content
|
||
// view) and that fix ships in gpui-pre fork-0.3.124, so every platform closes windows by
|
||
// destroying them. If the fix is ever disproven on real hardware, re-adding
|
||
// `--features macos-touchbar-window-hide` for the affected target here is the whole
|
||
// change; the mechanism is still in the code behind `HIDE_WINDOWS_ON_CLOSE`.
|
||
assert.doesNotMatch(
|
||
build,
|
||
/--features/,
|
||
"the build step must not name features: the Intel Mac Touch Bar workaround is disabled now that upstream zed#65186 landed",
|
||
);
|
||
assert.doesNotMatch(build, /extra_features/);
|
||
assert.match(
|
||
build,
|
||
/cargo build --release -p main --target "\$\{\{ matrix\.target \}\}"/,
|
||
);
|
||
assert.match(
|
||
build,
|
||
/test -x "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}"/,
|
||
);
|
||
|
||
assert.match(verifyBaseline, /if: runner\.os == 'Linux'/);
|
||
assert.match(
|
||
verifyBaseline,
|
||
/script\/check-linux-glibc-baseline\.sh[\s\S]*target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}[\s\S]*2\.28/,
|
||
);
|
||
|
||
assert.match(packageLinux, /mkdir -p package\/usr\/bin/);
|
||
assert.match(
|
||
packageLinux,
|
||
/cp "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}" package\/usr\/bin\//,
|
||
);
|
||
assert.match(packageLinux, /--sort=name/);
|
||
assert.match(packageLinux, /--numeric-owner/);
|
||
|
||
// The portable archive carries the identical binary plus the marker file the
|
||
// application looks for next to the executable. It has to be produced from
|
||
// this step without a second compilation, and its marker name must match the
|
||
// constant the Rust side actually reads.
|
||
const appPathsSource = read("crates/core/src/app_paths.rs");
|
||
const markerDeclaration =
|
||
/pub const PORTABLE_MARKER_FILE: &str = "([^"]+)";/.exec(appPathsSource);
|
||
assert.ok(markerDeclaration, "app_paths.rs must declare PORTABLE_MARKER_FILE");
|
||
const markerFile = markerDeclaration[1];
|
||
// Portable mode exists at all only because this detection stays platform
|
||
// independent; a Windows-only guard here would make the Linux archive ship a
|
||
// marker file that nothing ever reads.
|
||
assert.match(appPathsSource, /join\(PORTABLE_MARKER_FILE\)\.is_file\(\)/);
|
||
assert.doesNotMatch(appPathsSource, /cfg\(windows\)/);
|
||
assert.doesNotMatch(appPathsSource, /cfg\(target_os = "windows"\)/);
|
||
assert.match(packageLinux, new RegExp(`PORTABLE_MARKER_FILE="${markerFile}"`));
|
||
assert.match(packageLinux, /rm -rf portable-package/);
|
||
assert.match(packageLinux, /mkdir -p portable-package/);
|
||
assert.match(
|
||
packageLinux,
|
||
/cp "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}" portable-package\//,
|
||
);
|
||
assert.match(packageLinux, /: > "portable-package\/\$\{PORTABLE_MARKER_FILE\}"/);
|
||
assert.match(
|
||
packageLinux,
|
||
/-czf "\$\{PUBLIC_BASENAME\}-portable\.tar\.gz" \\\n\s+-C portable-package \./,
|
||
);
|
||
// Reusing the release binary is the whole point: no second compilation.
|
||
assert.doesNotMatch(packageLinux, /cargo (?:zig)?build/);
|
||
assert.match(
|
||
release,
|
||
/navop-\*-\$\{\{ matrix\.public_label \}\}-portable\.tar\.gz/,
|
||
);
|
||
// The Windows portable ZIP ships the same contract and hardcodes the same
|
||
// marker name, so both platforms have to agree with the Rust constant.
|
||
assert.match(
|
||
release,
|
||
new RegExp(`"portable-package/${markerFile.replace(/\./g, "\\.")}"`),
|
||
);
|
||
|
||
// The dependency stack is a second asset for the same target, built against
|
||
// the same glibc 2.28 baseline, and published under the versioned public name.
|
||
assert.match(packageGpuStack, /if: runner\.os == 'Linux'/);
|
||
assert.match(
|
||
packageGpuStack,
|
||
/script\/package-linux-gpu-stack-docker\.sh[\s\S]*--binary "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}"[\s\S]*--target "\$\{\{ matrix\.target \}\}"[\s\S]*--output dist-gpu-stack/,
|
||
);
|
||
assert.match(
|
||
packageGpuStack,
|
||
/cp dist-gpu-stack\/navop-gpu-stack-linux-\*\.tar\.gz "\$\{PUBLIC_BASENAME\}-gpu-stack\.tar\.gz"/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/navop-\*-\$\{\{ matrix\.public_label \}\}-gpu-stack\.tar\.gz/,
|
||
);
|
||
|
||
assert.match(packageInstallers, /if: matrix\.target == 'x86_64-unknown-linux-gnu'/);
|
||
});
|
||
|
||
test("the embedded webview is an opt-in feature on every platform", () => {
|
||
const workspaceCargo = read("Cargo.toml");
|
||
const cargo = read("crates/ai_chat_view/Cargo.toml");
|
||
const mainCargo = read("main/Cargo.toml");
|
||
const universalPluginsCargo = read("crates/universal-plugins/Cargo.toml");
|
||
const htmlCodeBlock = read(
|
||
"crates/ai_chat_view/src/html_code_block.rs",
|
||
);
|
||
const dependentCargoFiles = [
|
||
"main/Cargo.toml",
|
||
"crates/db_view/Cargo.toml",
|
||
"crates/mongodb_view/Cargo.toml",
|
||
"crates/redis_view/Cargo.toml",
|
||
"crates/terminal_view/Cargo.toml",
|
||
];
|
||
|
||
// The host webview (WebKitGTK 4.1 on Linux) cannot travel with the package, and
|
||
// an in-app HTML preview that works on two platforms but not the third is not
|
||
// worth a second release shape: the feature is off in the default set
|
||
// everywhere and has to be asked for explicitly.
|
||
assert.match(cargo, /^default = \[\]$/m);
|
||
assert.match(
|
||
cargo,
|
||
/embedded-webview = \["dep:gpui-webview", "dep:wry"\]/,
|
||
);
|
||
// The dependency key is `gpui-webview`; the workspace manifest pins it to
|
||
// the gpui-kit fork rev that ships the post-rename crate. On GPUI Fast the
|
||
// whole UI stack (webview included) shares the same backend.
|
||
assert.match(cargo, /gpui-webview = \{[^}]*optional = true[^}]*\}/);
|
||
assert.match(
|
||
workspaceCargo,
|
||
/^gpui-webview = \{ git = "https:\/\/github.com\/feigeCode\/gpui-kit",[^}]*\}$/m,
|
||
);
|
||
assert.match(cargo, /wry = \{[^}]*optional = true[^}]*\}/);
|
||
assert.doesNotMatch(cargo, /target_arch = "aarch64"/);
|
||
assert.match(
|
||
htmlCodeBlock,
|
||
/cfg\(feature = "embedded-webview"\)/,
|
||
);
|
||
assert.match(
|
||
htmlCodeBlock,
|
||
/cfg\(not\(feature = "embedded-webview"\)\)[\s\S]*?fn refresh_webview/,
|
||
);
|
||
assert.doesNotMatch(htmlCodeBlock, /target_arch = "aarch64"/);
|
||
assert.match(htmlCodeBlock, /HtmlPreview\.webview_unavailable/);
|
||
assert.match(
|
||
mainCargo,
|
||
/^default = \["wasm-components", "windows-native-rdp", "shell-plugins"\]$/m,
|
||
);
|
||
assert.doesNotMatch(mainCargo, /default = \[[^\]]*embedded-webview/);
|
||
assert.match(
|
||
mainCargo,
|
||
/embedded-webview = \["ai_chat_view\/embedded-webview"\]/,
|
||
);
|
||
assert.match(
|
||
mainCargo,
|
||
/shell-plugins = \["universal-plugins\/shell-plugins"\]/,
|
||
);
|
||
assert.match(
|
||
universalPluginsCargo,
|
||
/gpui-shell = \{[^}]*optional = true[^}]*\}/,
|
||
);
|
||
assert.match(
|
||
universalPluginsCargo,
|
||
/gpui-component-shell = \{[^}]*optional = true[^}]*\}/,
|
||
);
|
||
assert.match(
|
||
universalPluginsCargo,
|
||
/shell-plugins = \["dep:gpui-shell", "dep:gpui-component-shell"\]/,
|
||
);
|
||
assert.match(
|
||
workspaceCargo,
|
||
/ai_chat_view = \{ path = "crates\/ai_chat_view", default-features = false \}/,
|
||
);
|
||
for (const manifest of dependentCargoFiles) {
|
||
assert.match(
|
||
read(manifest),
|
||
/ai_chat_view = \{ workspace = true, default-features = false \}/,
|
||
`${manifest} must not implicitly enable ai_chat_view defaults`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("no release build enables the embedded webview, while the opt-in path still compiles", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const ci = read(".github/workflows/ci.yml");
|
||
|
||
// WebKitGTK 4.1 cannot travel with the package: the distributions that carry it
|
||
// sit on glibc 2.39+, and `WebKitWebProcess` is resolved through a compile-time
|
||
// path. Instead of giving Linux a feature set of its own, the feature is off in
|
||
// the default set (see main/Cargo.toml), so all three platforms publish the same
|
||
// shape and there is no per-platform list to keep in sync.
|
||
assert.doesNotMatch(release, /--features[^\n]*embedded-webview/);
|
||
const build = workflowStep(release, "Build release binary");
|
||
assert.doesNotMatch(build, /--no-default-features/);
|
||
assert.doesNotMatch(build, /--features[^\n]*embedded-webview/);
|
||
assert.doesNotMatch(build, /--features[^\n]*wasm-components/);
|
||
assert.match(
|
||
build,
|
||
/cargo zigbuild[\s\S]*--release[\s\S]*-p main[\s\S]*--target "\$\{\{ matrix\.target \}\}\.2\.28"/,
|
||
);
|
||
|
||
// Windows 32-bit is the one platform that has to name features explicitly (it
|
||
// cannot build shell-plugins). Its list must stay "the default set minus
|
||
// shell-plugins" instead of growing a webview back in.
|
||
assert.match(
|
||
release,
|
||
/--features", "wasm-components,windows-native-rdp"\)/,
|
||
);
|
||
|
||
// `cargo test --all` compiles the default set, meaning the half of the code
|
||
// without the feature. CI checks the other half, so the opt-in path keeps
|
||
// compiling instead of rotting until someone asks for it.
|
||
const optIn = workflowStep(ci, "Check the opt-in embedded webview feature");
|
||
assert.match(
|
||
optIn,
|
||
/cargo check -p ai_chat_view --features embedded-webview --tests/,
|
||
);
|
||
assert.match(optIn, /cargo check -p main --features embedded-webview/);
|
||
});
|
||
|
||
test("Linux GPU dependency stack replaces the retired portable runtime", () => {
|
||
const packagerPath = "script/package-linux-gpu-stack.py";
|
||
const wrapperPath = "script/package-linux-gpu-stack.sh";
|
||
const buildPath = "script/package-linux-gpu-stack-build.sh";
|
||
const dockerPath = "script/package-linux-gpu-stack-docker.sh";
|
||
const installPath = "script/linux-gpu-stack-install.sh";
|
||
|
||
for (const file of [
|
||
packagerPath,
|
||
wrapperPath,
|
||
buildPath,
|
||
dockerPath,
|
||
installPath,
|
||
]) {
|
||
assert.ok(fs.existsSync(file), `${file} must exist`);
|
||
}
|
||
// The launcher and the packager that produced it are deliberately gone: the
|
||
// stack now lands on the host loader instead of wrapping the binary.
|
||
for (const retired of [
|
||
"script/package-linux-portable.py",
|
||
"script/package-linux-portable.sh",
|
||
"script/linux-portable-launcher.c",
|
||
]) {
|
||
assert.equal(fs.existsSync(retired), false, `${retired} must be removed`);
|
||
}
|
||
|
||
const wrapper = read(wrapperPath);
|
||
const build = read(buildPath);
|
||
const docker = read(dockerPath);
|
||
const help = spawnSync("python3", [packagerPath, "--help"], {
|
||
encoding: "utf8",
|
||
});
|
||
|
||
assert.equal(help.status, 0, help.stderr);
|
||
assert.match(help.stdout, /x86_64-unknown-linux-gnu/);
|
||
assert.match(help.stdout, /aarch64-unknown-linux-gnu/);
|
||
|
||
assert.match(wrapper, /set -euo pipefail/);
|
||
assert.match(wrapper, /package-linux-gpu-stack\.py/);
|
||
// RHEL 8 images default to Python 3.6, so the wrapper must not trust it.
|
||
assert.match(wrapper, /sys\.version_info >= \(3, 9\)/);
|
||
|
||
// Only a RHEL 8 generation distribution ships a Mesa user space at the
|
||
// glibc 2.28 baseline Navop itself is built against, so dnf is what resolves
|
||
// the tree.
|
||
assert.match(build, /rpm -E %\{rhel\}/);
|
||
assert.match(build, /dnf install -y --setopt=install_weak_deps=False/);
|
||
assert.match(build, /dnf-plugins-core/);
|
||
assert.match(build, /dnf download --resolve/);
|
||
assert.match(
|
||
build,
|
||
/--releasever="\$release_version" --nogpgcheck --forcearch="\$rpm_arch"/,
|
||
);
|
||
assert.match(build, /--installroot "\$empty_root"/);
|
||
assert.match(build, /filter_rpms_to_architecture/);
|
||
assert.match(build, /rpm -qp --qf '%\{ARCH\}'/);
|
||
assert.match(build, /rpm2cpio "\$rpm" \| cpio -idm --no-absolute-filenames/);
|
||
assert.match(build, /mesa-dri-drivers/);
|
||
assert.match(build, /libglvnd-egl/);
|
||
assert.match(build, /libwayland-client/);
|
||
// The bare "liblzma" name does not exist on RHEL 8; the package is xz-libs.
|
||
assert.match(build, /xz-libs/);
|
||
assert.match(
|
||
build,
|
||
/local archive="navop-gpu-stack-linux-\$\{asset_label\}\.tar\.gz"/,
|
||
);
|
||
assert.match(build, /asset_label="x64"/);
|
||
assert.match(build, /asset_label="arm64"/);
|
||
assert.match(build, /--glibc-baseline "\$glibc_baseline"/);
|
||
assert.match(
|
||
build,
|
||
/--installer-source "\$repository_root\/script\/linux-gpu-stack-install\.sh"/,
|
||
);
|
||
|
||
// The host side mounts the repository read only plus the binary and the
|
||
// output directory, and must not rely on GNU-only find predicates.
|
||
assert.match(docker, /rockylinux:8/);
|
||
assert.match(docker, /-v "\$repository_root:\/workspace:ro"/);
|
||
assert.match(docker, /-v "\$binary_directory:\/binary:ro"/);
|
||
assert.match(docker, /-v "\$output:\/out"/);
|
||
assert.match(
|
||
docker,
|
||
/bash \/workspace\/script\/package-linux-gpu-stack-build\.sh/,
|
||
);
|
||
assert.match(docker, /navop-gpu-stack-linux-\*\.tar\.gz/);
|
||
assert.doesNotMatch(docker, /-newermt/);
|
||
});
|
||
|
||
test("Linux GPU dependency stack packager separates host libraries from bundled ones", () => {
|
||
const packagerPath = "script/package-linux-gpu-stack.py";
|
||
const python = String.raw`
|
||
import importlib.util
|
||
import sys
|
||
|
||
spec = importlib.util.spec_from_file_location("navop_gpu_stack_packager", sys.argv[1])
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[spec.name] = module
|
||
spec.loader.exec_module(module)
|
||
|
||
# Navop reaches EGL through dlopen, so these are roots rather than closure
|
||
# members, and Mesa resolves its driver by file name rather than by SONAME.
|
||
assert module.GPU_STACK_ENTRY_LIBRARIES == ("libEGL.so.1", "libEGL_mesa.so.0")
|
||
assert module.GPU_STACK_DRI_DRIVERS == ("swrast_dri.so", "kms_swrast_dri.so")
|
||
assert (
|
||
module.GPU_STACK_EGL_VENDOR_CONFIGURATION
|
||
== "/usr/share/glvnd/egl_vendor.d/50_mesa.json"
|
||
)
|
||
|
||
# The C runtime stays on the host: bundling it would shadow system glibc with a
|
||
# foreign copy.
|
||
for soname in ("libc.so.6", "libm.so.6", "libpthread.so.0", "ld-linux-x86-64.so.2"):
|
||
assert module.is_host_provided(soname), soname
|
||
assert module.is_host_provided("libnss_dns.so.2")
|
||
assert not module.is_host_provided("libEGL.so.1")
|
||
|
||
# The desktop stack integrates with the running session, so it is recorded as a
|
||
# host requirement instead of being shipped from one distribution.
|
||
for soname in (
|
||
"libgtk-3.so.0",
|
||
"libwebkit2gtk-4.1.so.0",
|
||
"libglib-2.0.so.0",
|
||
"libgobject-2.0.so.0",
|
||
"libpango-1.0.so.0",
|
||
"libcairo.so.2",
|
||
):
|
||
assert module.is_host_desktop_library(soname), soname
|
||
assert not module.is_host_desktop_library("libEGL.so.1")
|
||
|
||
# RHEL 8 is merged-/usr: an RPM that owns /lib64/libgcc_s.so.1 has to match the
|
||
# extracted /usr/lib64/libgcc_s.so.1, and the other way round.
|
||
assert module.path_spellings("/lib64/libgcc_s.so.1") == [
|
||
"/lib64/libgcc_s.so.1",
|
||
"/usr/lib64/libgcc_s.so.1",
|
||
]
|
||
assert module.path_spellings("/usr/lib64/libfoo.so") == [
|
||
"/lib64/libfoo.so",
|
||
"/usr/lib64/libfoo.so",
|
||
]
|
||
|
||
for name, config in module.TARGET_CONFIGS.items():
|
||
assert config.libdir == "/usr/lib64", name
|
||
assert config.dri_dir == "/usr/lib64/dri", name
|
||
assert config.dri_driver_directories, name
|
||
assert any(entry.endswith("/dri") for entry in config.dri_driver_directories), name
|
||
assert config.architecture_label in ("aarch64", "x86_64"), name
|
||
`;
|
||
const result = spawnSync("python3", ["-c", python, packagerPath], {
|
||
encoding: "utf8",
|
||
});
|
||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||
});
|
||
|
||
test("Linux GPU dependency stack installer is additive and reversibly removable", () => {
|
||
const installPath = "script/linux-gpu-stack-install.sh";
|
||
assert.ok(fs.existsSync(installPath), `${installPath} must exist`);
|
||
const installer = read(installPath);
|
||
|
||
assert.match(installer, /set -euo pipefail/);
|
||
assert.match(installer, /--dry-run/);
|
||
assert.match(installer, /--force/);
|
||
assert.match(installer, /--prefix/);
|
||
assert.match(installer, /--uninstall/);
|
||
|
||
// Everything lands on paths the loader already searches, so no package form
|
||
// needs environment variables to find it.
|
||
assert.match(installer, /resolve_package_root/);
|
||
assert.match(installer, /load_metadata/);
|
||
assert.match(installer, /verify_host_architecture/);
|
||
assert.match(installer, /ldconfig/);
|
||
assert.match(installer, /soname_is_resolved/);
|
||
assert.match(installer, /host_has_usable_gl/);
|
||
assert.match(installer, /host_has_dri_driver/);
|
||
assert.match(installer, /_dri\.so/);
|
||
|
||
// The host's own copy always wins: only gaps are filled.
|
||
assert.match(installer, /already provided by the host/);
|
||
assert.match(installer, /host copy kept/);
|
||
assert.match(installer, /install_gl=0/);
|
||
|
||
// Debian-style hosts do not search /usr/lib64, so it is registered, and the
|
||
// registration can be taken back.
|
||
assert.match(installer, /ensure_loader_configuration/);
|
||
assert.match(installer, /navop-gpu-stack\.conf/);
|
||
assert.match(installer, /\/etc\/ld\.so\.conf\.d/);
|
||
assert.match(installer, /drop_loader_configuration/);
|
||
assert.match(installer, /refresh_loader_cache/);
|
||
|
||
// Uninstall replays what this installer actually wrote, never the manifest it
|
||
// merely planned: that one also lists the entries the host already provided.
|
||
assert.match(installer, /installed\.tsv/);
|
||
assert.match(installer, /write_install_record/);
|
||
assert.match(installer, /KEPT_FILES\+=/);
|
||
assert.match(installer, /modified since installation/);
|
||
assert.match(installer, /no installation record at/);
|
||
|
||
const help = spawnSync("bash", [installPath, "--help"], { encoding: "utf8" });
|
||
assert.equal(help.status, 0, help.stderr);
|
||
assert.match(help.stdout, /--uninstall/);
|
||
// The help body is a sed range over the header comment; the last line must
|
||
// stay inside it.
|
||
assert.match(help.stdout, /\.\/install\.sh --help/);
|
||
});
|
||
|
||
test("Linux install guides document the GPU dependency stack and the portable archive", () => {
|
||
const guides = [
|
||
["docs-site/docs/guide/install-update.md", /图形依赖包/],
|
||
["docs-site/docs/en-US/guide/install-update.md", /graphics dependency package/],
|
||
["docs-site/docs/zh-TW/guide/install-update.md", /圖形相依套件/],
|
||
];
|
||
|
||
for (const [guidePath, heading] of guides) {
|
||
const guide = read(guidePath);
|
||
assert.match(guide, heading, `${guidePath} must document the dependency stack`);
|
||
assert.match(
|
||
guide,
|
||
/navop-<version>-linux-x64-gpu-stack\.tar\.gz/,
|
||
`${guidePath} must name the x86_64 dependency archive`,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/navop-<version>-linux-arm64-gpu-stack\.tar\.gz/,
|
||
`${guidePath} must name the arm64 dependency archive`,
|
||
);
|
||
// The documented flow has to match the archive layout, the installer name
|
||
// and the flags the script actually accepts.
|
||
assert.match(guide, /tar -xzf navop-<version>-linux-x64-gpu-stack\.tar\.gz -C navop-gpu-stack/);
|
||
assert.match(guide, /sudo navop-gpu-stack\/install\.sh/);
|
||
assert.match(guide, /--dry-run/);
|
||
assert.match(guide, /--force/);
|
||
assert.match(guide, /--uninstall/);
|
||
assert.match(guide, /installed\.tsv/);
|
||
assert.match(guide, /\/usr\/lib\/navop-gpu-stack\//);
|
||
assert.match(guide, /Failed to create surface/);
|
||
// The portable archive ships again, now as the very same binary plus a
|
||
// marker file instead of a private loader, so every guide has to describe
|
||
// the archive names and the marker contract.
|
||
assert.match(
|
||
guide,
|
||
/navop-<version>-linux-x64-portable\.tar\.gz/,
|
||
`${guidePath} must name the x86_64 portable archive`,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/navop-<version>-linux-arm64-portable\.tar\.gz/,
|
||
`${guidePath} must name the arm64 portable archive`,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/navop\.portable/,
|
||
`${guidePath} must document the marker file`,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/--portable\b/,
|
||
`${guidePath} must document the --portable flag`,
|
||
);
|
||
assert.match(
|
||
guide,
|
||
/NAVOP_PORTABLE/,
|
||
`${guidePath} must document the NAVOP_PORTABLE environment variable`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("glibc baseline checker rejects binaries above the configured version", () => {
|
||
const checker = "script/check-linux-glibc-baseline.sh";
|
||
assert.ok(fs.existsSync(checker), `${checker} must exist`);
|
||
|
||
const fixtureDir = fs.mkdtempSync(
|
||
path.join(os.tmpdir(), "navop-glibc-check-"),
|
||
);
|
||
const fakeReadelf = path.join(fixtureDir, "readelf");
|
||
const binary = path.join(fixtureDir, "navop");
|
||
fs.writeFileSync(binary, "");
|
||
|
||
const runChecker = (readelfOutput) => {
|
||
fs.writeFileSync(
|
||
fakeReadelf,
|
||
`#!/usr/bin/env bash\ncat <<'EOF'\n${readelfOutput}\nEOF\n`,
|
||
{ mode: 0o755 },
|
||
);
|
||
return spawnSync("bash", [checker, binary, "2.28"], {
|
||
encoding: "utf8",
|
||
env: { ...process.env, READELF: fakeReadelf },
|
||
});
|
||
};
|
||
|
||
try {
|
||
const compatible = runChecker(
|
||
"Name: GLIBC_2.17\nName: GLIBC_2.28",
|
||
);
|
||
assert.equal(compatible.status, 0, compatible.stderr);
|
||
assert.match(compatible.stdout, /highest required GLIBC version: 2\.28/);
|
||
|
||
const incompatible = runChecker(
|
||
"Name: GLIBC_2.17\nName: GLIBC_2.29",
|
||
);
|
||
assert.notEqual(incompatible.status, 0);
|
||
assert.match(incompatible.stderr, /requires GLIBC_2\.29/);
|
||
|
||
const missingSymbols = runChecker("No version information found");
|
||
assert.notEqual(missingSymbols.status, 0);
|
||
assert.match(missingSymbols.stderr, /did not report any GLIBC versions/);
|
||
} finally {
|
||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("Windows release builds an installable per-user MSI", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const wix = read("installer/windows/navop.wxs");
|
||
|
||
assert.match(release, /dotnet tool install --global wix --version 6\.0\.2/);
|
||
assert.match(release, /wix build installer\/windows\/navop\.wxs/);
|
||
assert.match(
|
||
release,
|
||
/-out "\$\{env:PUBLIC_BASENAME\}\.msi"/,
|
||
);
|
||
assert.match(wix, /Scope="perUser"/);
|
||
assert.match(wix, /StandardDirectory Id="LocalAppDataFolder"/);
|
||
assert.match(wix, /<File[^>]+Source="\$\(SourceDir\)\\navop\.exe"/);
|
||
assert.match(wix, /MajorUpgrade/);
|
||
assert.match(wix, /ProgramMenuFolder/);
|
||
assert.match(wix, /Shortcut[^]*Name="Navop"/);
|
||
assert.match(wix, /RemoveFolder[^]*On="uninstall"/);
|
||
assert.match(wix, /Root="HKCU"/);
|
||
});
|
||
|
||
test("Windows application builds include the native RDP backend", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const releaseWindowsBuild = release.match(
|
||
/- name: Build release binary \(Windows\)[\s\S]*?(?=\n - name:)/,
|
||
)?.[0];
|
||
|
||
assert.ok(releaseWindowsBuild, "missing Windows release binary build step");
|
||
for (const target of [
|
||
"aarch64-apple-darwin",
|
||
"x86_64-apple-darwin",
|
||
"x86_64-unknown-linux-gnu",
|
||
"aarch64-unknown-linux-gnu",
|
||
]) {
|
||
assert.match(
|
||
release,
|
||
new RegExp(
|
||
`"target":"${target}"[^']*"windows_native_rdp":false`,
|
||
),
|
||
);
|
||
}
|
||
for (const target of [
|
||
"x86_64-pc-windows-msvc",
|
||
"i686-pc-windows-msvc",
|
||
]) {
|
||
assert.match(
|
||
release,
|
||
new RegExp(
|
||
`"target":"${target}"[^']*"windows_native_rdp":true`,
|
||
),
|
||
);
|
||
}
|
||
assert.match(
|
||
releaseWindowsBuild,
|
||
/if: runner\.os == 'Windows'/,
|
||
);
|
||
assert.match(
|
||
releaseWindowsBuild,
|
||
/"windows-native-rdp"/,
|
||
);
|
||
assert.match(
|
||
releaseWindowsBuild,
|
||
/--target \$target/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/- name: Configure MSVC environment[\s\S]*?if: runner\.os == 'Windows'[\s\S]*?uses: ilammy\/msvc-dev-cmd@v1[\s\S]*?arch: \$\{\{ matrix\.windows_arch \}\}/,
|
||
);
|
||
assert.match(releaseWindowsBuild, /VCToolsInstallDir/);
|
||
});
|
||
|
||
test("Windows release publishes versioned Win32 artifacts while preserving updater metadata", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const upload = read(".github/workflows/upload-r2.yml");
|
||
const cargoConfig = read(".cargo/config.toml");
|
||
|
||
assert.match(release, /- windows-x86/);
|
||
assert.match(
|
||
release,
|
||
/windows_x86='\{"target":"i686-pc-windows-msvc"[^']*"archive":"navop-i686-pc-windows-msvc\.zip"[^']*"public_label":"win32"[^']*"windows_arch":"x86"/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_arm64,\$windows_x64,\$windows_x86\]"/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/\$\{env:PUBLIC_BASENAME\}-portable\.zip/,
|
||
);
|
||
assert.match(release, /-arch \$\{\{ matrix\.windows_arch \}\}/);
|
||
assert.match(release, /\$\{env:PUBLIC_BASENAME\}\.msi/);
|
||
assert.match(release, /\$\{env:PUBLIC_BASENAME\}\.exe/);
|
||
assert.match(release, /PUBLIC_BASENAME=navop-\$\{VERSION#v\}-\$\{\{ matrix\.public_label \}\}/);
|
||
|
||
assert.match(upload, /navop-i686-pc-windows-msvc\.zip/);
|
||
assert.match(
|
||
upload,
|
||
/"i686-pc-windows-msvc": "navop-i686-pc-windows-msvc\.zip"/,
|
||
);
|
||
assert.match(
|
||
cargoConfig,
|
||
/\[target\.i686-pc-windows-msvc\][\s\S]*?link-arg=\/STACK:8000000/,
|
||
);
|
||
});
|
||
|
||
test("Windows release builds an EXE installer bundle from the MSI", () => {
|
||
const bundlePath = "installer/windows/navop-bundle.wxs";
|
||
assert.ok(fs.existsSync(bundlePath), `${bundlePath} must exist`);
|
||
|
||
const bundle = read(bundlePath);
|
||
const release = read(".github/workflows/release.yml");
|
||
|
||
assert.match(
|
||
bundle,
|
||
/xmlns:bal="http:\/\/wixtoolset\.org\/schemas\/v4\/wxs\/bal"/,
|
||
);
|
||
assert.match(bundle, /<Bundle[^>]*Id="feigeCode\.Navop"/);
|
||
assert.doesNotMatch(bundle, /UpgradeCode=/);
|
||
assert.match(bundle, /<bal:WixInternalUIBootstrapperApplication\s*\/>/);
|
||
assert.match(
|
||
bundle,
|
||
/<MsiPackage[^>]*SourceFile="\$\(MsiPath\)"[^>]*Compressed="yes"[^>]*bal:PrimaryPackageType="default"/,
|
||
);
|
||
assert.doesNotMatch(bundle, /bal:PrimaryPackageType="x64"/);
|
||
|
||
assert.match(
|
||
release,
|
||
/WixToolset\.BootstrapperApplications\.wixext\/6\.0\.2/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/wix build installer\/windows\/navop-bundle\.wxs[^]*-ext WixToolset\.BootstrapperApplications\.wixext[^]*-d Version=[^\n]+[^]*-d MsiPath=[^\n]*\$\{env:PUBLIC_BASENAME\}\.msi[^]*-out "\$\{env:PUBLIC_BASENAME\}\.exe"/,
|
||
);
|
||
assert.doesNotMatch(
|
||
release,
|
||
/Copy-Item[^\n]+"navop-x86_64-pc-windows-msvc\.exe"/,
|
||
);
|
||
|
||
const msiBuild = release.indexOf(
|
||
"wix build installer/windows/navop.wxs",
|
||
);
|
||
const bundleBuild = release.indexOf(
|
||
"wix build installer/windows/navop-bundle.wxs",
|
||
);
|
||
assert.ok(msiBuild >= 0, "missing MSI build");
|
||
assert.ok(bundleBuild > msiBuild, "EXE installer must be built after MSI");
|
||
});
|
||
|
||
test("Windows release keeps the legacy ZIP standard and publishes portable separately", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const installGuides = [
|
||
read("docs-site/docs/guide/install-update.md"),
|
||
read("docs-site/docs/en-US/guide/install-update.md"),
|
||
read("docs-site/docs/zh-TW/guide/install-update.md"),
|
||
];
|
||
|
||
assert.match(release, /portable-package/);
|
||
assert.match(release, /navop\.portable/);
|
||
assert.match(
|
||
release,
|
||
/Set-Content -Path "portable-package\/navop\.portable"/,
|
||
);
|
||
assert.doesNotMatch(
|
||
release,
|
||
/"package\/navop\.portable"/,
|
||
);
|
||
assert.match(release, /-d SourceDir=.*\\package/);
|
||
assert.doesNotMatch(release, /-d SourceDir=.*portable-package/);
|
||
assert.match(
|
||
release,
|
||
/Compress-Archive -Path "package\/\*" -DestinationPath "\$\{\{ matrix\.archive \}\}"/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/Compress-Archive -Path "portable-package\/\*" -DestinationPath "\$\{env:PUBLIC_BASENAME\}-portable\.zip"/,
|
||
);
|
||
assert.match(release, /navop-x86_64-pc-windows-msvc\.zip/);
|
||
assert.match(
|
||
release,
|
||
/windows_x64='\{"target":"x86_64-pc-windows-msvc"[^']*"archive":"navop-x86_64-pc-windows-msvc\.zip"/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/name: navop-\$\{\{ matrix\.public_label \}\}-packages/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/navop-\*-\$\{\{ matrix\.public_label \}\}-portable\.zip/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/navop-\*-\$\{\{ matrix\.public_label \}\}\.msi/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/navop-\*-\$\{\{ matrix\.public_label \}\}\.exe/,
|
||
);
|
||
for (const [guide, installerLabel] of [
|
||
[installGuides[0], /EXE 安装包/],
|
||
[installGuides[1], /EXE installer/],
|
||
[installGuides[2], /EXE 安裝包/],
|
||
]) {
|
||
assert.match(guide, /navop-<version>-windows-x64\.exe/);
|
||
assert.match(guide, installerLabel);
|
||
assert.match(guide, /-portable\.zip/);
|
||
assert.doesNotMatch(
|
||
guide,
|
||
/(?:独立 EXE|獨立 EXE|standalone EXE|standalone \.exe|官方 Windows ZIP 已包含|官方 Windows \.zip 是便携版|The official Windows ZIP already includes|The official Windows \.zip is the portable edition|官方 Windows ZIP 已包含|官方 Windows \.zip 是便攜版)/,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("Windows MSI appends Navop to the directory chosen by users", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const wix = read("installer/windows/navop.wxs");
|
||
|
||
assert.match(
|
||
release,
|
||
/wix extension add -g WixToolset\.UI\.wixext\/6\.0\.2/,
|
||
);
|
||
assert.match(
|
||
release,
|
||
/wix build installer\/windows\/navop\.wxs[^]*-ext WixToolset\.UI\.wixext/,
|
||
);
|
||
assert.match(
|
||
wix,
|
||
/xmlns:ui="http:\/\/wixtoolset\.org\/schemas\/v4\/wxs\/ui"/,
|
||
);
|
||
assert.match(
|
||
wix,
|
||
/<ui:WixUI[^>]*Id="WixUI_InstallDir"[^>]*InstallDirectory="INSTALLROOT"/,
|
||
);
|
||
assert.match(
|
||
wix,
|
||
/<Directory Id="INSTALLROOT" Name="Programs">\s*<Directory Id="INSTALLFOLDER" Name="Navop"/,
|
||
);
|
||
assert.doesNotMatch(wix, /InstallDirectory="INSTALLFOLDER"/);
|
||
});
|
||
|
||
test("Windows MSI builds one bilingual localized installer", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const wix = read("installer/windows/navop.wxs");
|
||
const localizationPath = "installer/windows/navop.wxl";
|
||
const licensePath = "installer/windows/navop-license.rtf";
|
||
|
||
assert.match(wix, /Language="1033"/);
|
||
assert.match(wix, /Codepage="936"/);
|
||
assert.match(wix, /WixUILicenseRtf[^]*navop-license\.rtf/);
|
||
assert.match(release, /node script\/generate-windows-license\.mjs/);
|
||
assert.match(release, /-culture en-US/);
|
||
assert.match(release, /-loc installer\/windows\/navop\.wxl/);
|
||
assert.equal(
|
||
(release.match(/wix build installer\/windows\/navop\.wxs/g) ?? [])
|
||
.length,
|
||
1,
|
||
);
|
||
assert.doesNotMatch(release, /navop-x86_64-pc-windows-msvc-zh-CN\.msi/);
|
||
|
||
assert.ok(fs.existsSync(localizationPath), `${localizationPath} must exist`);
|
||
const localization = read(localizationPath);
|
||
assert.match(localization, /Estimated time remaining/);
|
||
assert.match(localization, /预计剩余时间/);
|
||
assert.match(localization, /I have read and accept/);
|
||
assert.match(localization, /我已阅读并同意/);
|
||
|
||
assert.ok(fs.existsSync(licensePath), `${licensePath} must exist`);
|
||
const license = read(licensePath);
|
||
assert.match(license, /Apache License/);
|
||
assert.match(license, /Navop Software License Agreement/);
|
||
assert.match(license, /\\u/);
|
||
assert.doesNotMatch(license, /Lorem ipsum/);
|
||
});
|
||
|
||
test("Windows MSI creates a desktop shortcut", () => {
|
||
const wix = read("installer/windows/navop.wxs");
|
||
|
||
assert.match(wix, /<StandardDirectory Id="DesktopFolder"\s*\/>/);
|
||
assert.match(
|
||
wix,
|
||
/<Shortcut[^>]*Id="DesktopShortcut"[^>]*Name="Navop"/,
|
||
);
|
||
});
|
||
|
||
test("Windows MSI shortcuts use dedicated HKCU-keyed components", () => {
|
||
const wix = read("installer/windows/navop.wxs");
|
||
const component = (id) => {
|
||
const match = wix.match(
|
||
new RegExp(`<Component\\s+Id="${id}"[^>]*>([\\s\\S]*?)<\\/Component>`),
|
||
);
|
||
assert.ok(match, `missing ${id} component`);
|
||
return match[0];
|
||
};
|
||
|
||
const executable = component("ApplicationExecutable");
|
||
assert.doesNotMatch(executable, /<Shortcut\b/);
|
||
|
||
for (const [componentId, directory, shortcutId, registryName] of [
|
||
[
|
||
"StartMenuShortcutComponent",
|
||
"ApplicationProgramsFolder",
|
||
"StartMenuShortcut",
|
||
"StartMenuShortcutInstalled",
|
||
],
|
||
[
|
||
"DesktopShortcutComponent",
|
||
"DesktopFolder",
|
||
"DesktopShortcut",
|
||
"DesktopShortcutInstalled",
|
||
],
|
||
]) {
|
||
const shortcutComponent = component(componentId);
|
||
assert.match(
|
||
shortcutComponent,
|
||
new RegExp(`<Component[^>]*Directory="${directory}"`),
|
||
);
|
||
assert.match(
|
||
shortcutComponent,
|
||
new RegExp(
|
||
`<Shortcut[^>]*Id="${shortcutId}"[^>]*Target="\\[#NavopExecutable\\]"[^>]*Advertise="no"`,
|
||
),
|
||
);
|
||
assert.match(
|
||
shortcutComponent,
|
||
new RegExp(
|
||
`<RegistryValue[^>]*Root="HKCU"[^>]*Name="${registryName}"[^>]*KeyPath="yes"`,
|
||
),
|
||
);
|
||
}
|
||
});
|
||
|
||
test("Windows MSI shortcuts inherit the executable's embedded icon", () => {
|
||
const wix = read("installer/windows/navop.wxs");
|
||
|
||
// 快捷方式不能引用 Icon 表:MSI 会把 Icon 表里的图标另存成一份文件,该文件被清理或
|
||
// 图标缓存失效后,快捷方式图标会退化成通用白纸(issue #325)。目标 exe 已内嵌图标。
|
||
for (const id of ["StartMenuShortcut", "DesktopShortcut"]) {
|
||
const shortcut = wix.match(
|
||
new RegExp(`<Shortcut\\b[^>]*Id="${id}"[^>]*/>`),
|
||
);
|
||
assert.ok(shortcut, `missing ${id} shortcut`);
|
||
assert.doesNotMatch(shortcut[0], /\bIcon=/);
|
||
}
|
||
|
||
// 控制面板的卸载项仍走 Icon 表;快捷方式图标则由 exe 的内嵌资源提供。
|
||
assert.match(wix, /<Icon Id="NavopIcon" SourceFile="\$\(IconPath\)"\s*\/>/);
|
||
assert.match(wix, /<Property Id="ARPPRODUCTICON" Value="NavopIcon"\s*\/>/);
|
||
assert.match(
|
||
read("main/build.rs"),
|
||
/set_icon\("\.\.\/resources\/windows\/navop\.ico"\)/,
|
||
);
|
||
});
|
||
|
||
test("GitHub and R2 publish every installer while the updater manifest remains compatible", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const upload = read(".github/workflows/upload-r2.yml");
|
||
|
||
assert.match(
|
||
release,
|
||
/name: navop-\$\{\{ matrix\.public_label \}\}-packages[\s\S]*?navop-\*-\$\{\{ matrix\.public_label \}\}\.msi/,
|
||
);
|
||
assert.match(release, /new_files=\(artifacts\/navop-\* artifacts\/navop_\*\)/);
|
||
// Every asset, including the Linux GPU dependency stack, is matched by the
|
||
// release globs rather than being enumerated by hand.
|
||
assert.match(release, /navop-\*-\$\{\{ matrix\.public_label \}\}-gpu-stack\.tar\.gz/);
|
||
assert.match(upload, /--pattern "navop-\*"/);
|
||
assert.match(upload, /--pattern "navop_\*"/);
|
||
assert.match(upload, /release_files=\(artifacts\/navop-\* artifacts\/navop_\*\)/);
|
||
assert.match(upload, /schema_version: 1/);
|
||
assert.match(upload, /downloads: objectUrls\("releases", updaterAssets\)/);
|
||
assert.match(upload, /fallback_downloads: githubReleaseUrls\(updaterAssets\)/);
|
||
assert.match(upload, /sha256s: objectChecksums\(updaterAssets\)/);
|
||
assert.match(upload, /packages,/);
|
||
assert.match(upload, /publicUpdaterAlternatives/);
|
||
assert.match(upload, /`navop-\$\{version\}-win32\.zip`/);
|
||
assert.match(upload, /\["win32", "i686-pc-windows-msvc"\]/);
|
||
// The portable archives and the dependency stack are extra assets for the
|
||
// same target, so they must map onto that target instead of falling through
|
||
// to "universal", and the distinguishing suffix has to survive in the target
|
||
// name so a download page can tell them apart.
|
||
assert.match(upload, /fileName\.includes\(`-\$\{label\}-gpu-stack\.`\)/);
|
||
assert.match(upload, /fileName\.includes\(`-\$\{label\}-portable\.`\)/);
|
||
assert.match(upload, /return `\$\{publicTarget\[1\]\}-portable`/);
|
||
assert.match(upload, /return `\$\{publicTarget\[1\]\}-gpu-stack`/);
|
||
assert.doesNotMatch(upload, /linux-x64-portable|linux-arm64-portable/);
|
||
assert.match(upload, /\*\.dmg\) content_type="application\/x-apple-diskimage"/);
|
||
assert.match(upload, /\*\.msi\) content_type="application\/x-msi"/);
|
||
assert.match(upload, /\*\.exe\) content_type="application\/vnd\.microsoft\.portable-executable"/);
|
||
assert.match(upload, /\*\.deb\) content_type="application\/vnd\.debian\.binary-package"/);
|
||
assert.match(upload, /\*\.rpm\) content_type="application\/x-rpm"/);
|
||
assert.match(upload, /\*\.AppImage\) content_type="application\/octet-stream"/);
|
||
});
|
||
|
||
test("R2 uploads are single-dispatch, revalidated, and verified after overwrite", () => {
|
||
const upload = read(".github/workflows/upload-r2.yml");
|
||
|
||
assert.match(upload, /workflow_dispatch:/);
|
||
assert.doesNotMatch(upload, /workflow_run:/);
|
||
assert.match(upload, /group: \$\{\{ github\.workflow \}\}-\$\{\{ inputs\.tag \}\}/);
|
||
assert.match(upload, /cancel-in-progress: false/);
|
||
assert.match(upload, /--metadata "sha256=\$\{expected_sha256\}"/);
|
||
assert.match(upload, /aws s3api head-object/);
|
||
assert.match(upload, /R2 object size mismatch/);
|
||
assert.match(upload, /R2 object checksum metadata mismatch/);
|
||
assert.match(upload, /public, max-age=0, must-revalidate/);
|
||
assert.match(upload, /no-store, max-age=0/);
|
||
assert.doesNotMatch(upload, /max-age=31536000/);
|
||
assert.doesNotMatch(upload, /max-age=31536000, immutable/);
|
||
});
|
||
|
||
test("R2 keeps only the release it just uploaded", () => {
|
||
const upload = read(".github/workflows/upload-r2.yml");
|
||
|
||
// 清理必须排在上传与逐对象校验之后,而且不能总是执行:上传失败时一个对象
|
||
// 都不能删,否则桶里可能新旧两份都不完整。
|
||
assert.ok(
|
||
upload.indexOf("Prune older R2 releases") >
|
||
upload.indexOf("Upload update archives and manifest to R2"),
|
||
);
|
||
const pruneStep = workflowStep(upload, "Prune older R2 releases");
|
||
assert.doesNotMatch(pruneStep, /always\(\)/);
|
||
assert.doesNotMatch(pruneStep, /continue-on-error/);
|
||
assert.match(
|
||
pruneStep,
|
||
/if: \$\{\{ steps\.r2_config\.outputs\.skip != 'true' && steps\.release\.outputs\.skip != 'true' \}\}/,
|
||
);
|
||
// 只动 releases/<tag>/:updates/latest.json 是更新入口,任何时候都不能删。
|
||
assert.match(pruneStep, /prefix="releases"/);
|
||
assert.doesNotMatch(pruneStep, /updates\//);
|
||
// 只有稳定版本号才清理,预发布不能把上一个稳定版本的安装包带走。
|
||
assert.match(pruneStep, /=\~ \^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$ \]\]/);
|
||
assert.match(pruneStep, /Skipping R2 prune for non-stable release tag/);
|
||
|
||
const pruneRun = pruneStep
|
||
.match(/run: \|\n([\s\S]*)/)?.[1]
|
||
.split("\n")
|
||
.map((line) => line.replace(/^ {10}/, ""))
|
||
.join("\n");
|
||
assert.ok(pruneRun, "prune step must have a run script");
|
||
|
||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "navop-r2-prune-"));
|
||
const statePath = path.join(fixtureDir, "bucket");
|
||
const deletedPath = path.join(fixtureDir, "deleted");
|
||
const fakeAws = path.join(fixtureDir, "aws");
|
||
|
||
// 假 aws 只实现这一步用到的两条命令:列出版本前缀、递归删掉一个前缀。删除
|
||
// 会真的从 fixture 里拿掉版本,所以脚本最后的复查也一起被验证到。
|
||
fs.writeFileSync(
|
||
fakeAws,
|
||
`#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
case "\${1:-} \${2:-}" in
|
||
"s3api list-objects-v2")
|
||
# 必须带 delimiter,否则列出来的是对象而不是版本目录。
|
||
if [[ " $* " != *" --delimiter / "* ]]; then
|
||
echo "s3api list-objects-v2 without --delimiter: $*" >&2
|
||
exit 42
|
||
fi
|
||
# 跟真实 aws cli 一致:--output text 对扁平列表是「一行、tab 拼接」。
|
||
# 之前这里按一行一个写,恰好把真实现场的解析 bug 掩盖过去了。
|
||
separator=""
|
||
line=""
|
||
for tag in $(grep -v '^$' "\${FAKE_R2_STATE}"); do
|
||
line="\${line}\${separator}releases/\$tag/"
|
||
separator=\$'\t'
|
||
done
|
||
[[ -z "\$line" ]] || printf '%s\\n' "\$line"
|
||
;;
|
||
"s3 rm")
|
||
target=""
|
||
for argument in "\$@"; do
|
||
case "\$argument" in s3://*) target="\$argument" ;; esac
|
||
done
|
||
key="\${target#s3://*/}"
|
||
key="\${key%/}"
|
||
echo "\$key" >> "\${FAKE_R2_DELETED}"
|
||
grep -Fxv "\${key#releases/}" "\${FAKE_R2_STATE}" > "\${FAKE_R2_STATE}.tmp"
|
||
mv "\${FAKE_R2_STATE}.tmp" "\${FAKE_R2_STATE}"
|
||
;;
|
||
*)
|
||
echo "unexpected aws invocation: \$*" >&2
|
||
exit 42
|
||
;;
|
||
esac
|
||
`,
|
||
{ mode: 0o755 },
|
||
);
|
||
|
||
const runPrune = (tags, releaseTag = "v0.19.5") => {
|
||
fs.writeFileSync(statePath, `${tags.join("\n")}\n`);
|
||
fs.writeFileSync(deletedPath, "");
|
||
const result = spawnSync("bash", ["-c", pruneRun], {
|
||
encoding: "utf8",
|
||
env: {
|
||
...process.env,
|
||
PATH: `${fixtureDir}:${process.env.PATH}`,
|
||
FAKE_R2_STATE: statePath,
|
||
FAKE_R2_DELETED: deletedPath,
|
||
CLOUDFLARE_ACCOUNT_ID: "test-account",
|
||
CLOUDFLARE_R2_BUCKET: "test-bucket",
|
||
RELEASE_TAG: releaseTag,
|
||
},
|
||
});
|
||
return {
|
||
result,
|
||
remaining: fs
|
||
.readFileSync(statePath, "utf8")
|
||
.trim()
|
||
.split("\n")
|
||
.filter(Boolean),
|
||
deleted: fs
|
||
.readFileSync(deletedPath, "utf8")
|
||
.trim()
|
||
.split("\n")
|
||
.filter(Boolean)
|
||
.sort(),
|
||
};
|
||
};
|
||
|
||
try {
|
||
const pruned = runPrune(["v0.19.5", "v0.19.4", "v0.19.3"]);
|
||
assert.equal(pruned.result.status, 0, pruned.result.stderr);
|
||
assert.deepEqual(pruned.remaining, ["v0.19.5"]);
|
||
assert.deepEqual(pruned.deleted, [
|
||
"releases/v0.19.3",
|
||
"releases/v0.19.4",
|
||
]);
|
||
assert.match(pruned.result.stdout, /R2 now keeps only releases\/v0\.19\.5\//);
|
||
|
||
// 本次 tag 不在桶里(上传其实没成功)时,一个旧版本都不能删。
|
||
const missing = runPrune(["v0.19.4", "v0.19.3"]);
|
||
assert.notEqual(missing.result.status, 0);
|
||
assert.match(missing.result.stdout, /Refusing to prune/);
|
||
assert.deepEqual(missing.deleted, []);
|
||
assert.deepEqual(missing.remaining, ["v0.19.4", "v0.19.3"]);
|
||
|
||
// 预发布不清理,既有稳定版本原样保留。
|
||
const prerelease = runPrune(["v0.19.5", "v0.19.4"], "v0.20.0-rc.1");
|
||
assert.equal(prerelease.result.status, 0, prerelease.result.stderr);
|
||
assert.deepEqual(prerelease.deleted, []);
|
||
assert.deepEqual(prerelease.remaining, ["v0.19.5", "v0.19.4"]);
|
||
assert.match(
|
||
prerelease.result.stdout,
|
||
/Skipping R2 prune for non-stable release tag/,
|
||
);
|
||
} finally {
|
||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("CNB release synchronization replaces moved tags before syncing assets", () => {
|
||
const sync = read(".github/workflows/sync-cnb-release-assets.yml");
|
||
|
||
assert.match(sync, /uses: actions\/checkout@v4/);
|
||
assert.match(sync, /fetch-depth: 0/);
|
||
assert.match(sync, /ref: \$\{\{ inputs\.tag \}\}/);
|
||
assert.match(sync, /group: navop-cnb-release/);
|
||
assert.match(sync, /cancel-in-progress: false/);
|
||
assert.match(sync, /git remote add cnb "https:\/\/cnb\.cool\/\$\{CNB_REPOSITORY\}\.git"/);
|
||
assert.match(sync, /git ls-remote --tags cnb/);
|
||
assert.match(sync, /git for-each-ref --format='%\(refname\)' refs\/tags/);
|
||
assert.match(sync, /git push cnb ":refs\/tags\/\$\{tag_name\}"/);
|
||
assert.match(sync, /git push cnb --tags/);
|
||
assert.match(sync, /\.\/mpgrm releases sync/);
|
||
|
||
const deleteMovedTag = sync.indexOf('git push cnb ":refs/tags/${tag_name}"');
|
||
const pushTags = sync.indexOf("git push cnb --tags");
|
||
const syncAssets = sync.indexOf("./mpgrm releases sync");
|
||
assert.ok(deleteMovedTag >= 0 && deleteMovedTag < pushTags);
|
||
assert.ok(pushTags >= 0 && pushTags < syncAssets);
|
||
});
|
||
|
||
test("CI runs release packaging regression checks", () => {
|
||
const ci = read(".github/workflows/ci.yml");
|
||
|
||
assert.match(ci, /node --test script\/test-release-packaging\.mjs/);
|
||
assert.match(ci, /workflow_dispatch:/);
|
||
assert.match(ci, /- windows/);
|
||
assert.match(ci, /fromJSON\(needs\.prepare\.outputs\.matrix\)/);
|
||
});
|
||
|
||
test("Windows release validates the MSI installer with the shared validator", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const validatorPath = "script/validate-windows-msi.ps1";
|
||
assert.ok(fs.existsSync(validatorPath), `${validatorPath} must exist`);
|
||
assert.match(release, /validate-windows-msi\.ps1/);
|
||
|
||
const validator = read(validatorPath);
|
||
assert.match(validator, /ProductLanguage/);
|
||
assert.match(validator, /WIXUI_INSTALLDIR/);
|
||
assert.match(validator, /DesktopShortcut/);
|
||
assert.match(validator, /StartMenuShortcut/);
|
||
assert.match(validator, /DesktopShortcutComponent/);
|
||
assert.match(validator, /StartMenuShortcutComponent/);
|
||
assert.match(validator, /DesktopShortcutRegistry/);
|
||
assert.match(validator, /StartMenuShortcutRegistry/);
|
||
assert.match(validator, /SELECT Component_ FROM Shortcut/);
|
||
assert.match(validator, /Assert-MsiEmptyValue/);
|
||
assert.match(validator, /SELECT Icon_ FROM Shortcut/);
|
||
assert.match(validator, /SELECT KeyPath FROM Component/);
|
||
assert.match(validator, /SELECT Root FROM Registry/);
|
||
assert.match(validator, /\.Trim\(\)/);
|
||
assert.match(validator, /\$null = \$view\.Execute\(\)/);
|
||
assert.match(validator, /\$null = \$view\.Close\(\)/);
|
||
assert.match(validator, /\$value = \[string\]\$record\.StringData\(1\)/);
|
||
});
|
||
|
||
test("release builds use fat LTO with panic=abort and a flat 180min timeout", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
assert.doesNotMatch(release, /^\s+CARGO_PROFILE_RELEASE_LTO:\s/m);
|
||
assert.doesNotMatch(release, /^\s+CARGO_PROFILE_RELEASE_CODEGEN_UNITS:\s/m);
|
||
assert.match(release, /export CARGO_PROFILE_RELEASE_LTO=thin/);
|
||
assert.match(release, /export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16/);
|
||
assert.match(release, /timeout-minutes: 180/);
|
||
|
||
const cargo = read("Cargo.toml");
|
||
assert.match(cargo, /\[profile\.release\][\s\S]*?lto = "fat"/);
|
||
assert.match(cargo, /\[profile\.release\][\s\S]*?codegen-units = 1/);
|
||
assert.match(cargo, /\[profile\.release\][\s\S]*?panic = "abort"/);
|
||
});
|
||
|
||
test("release builds are cacheable and individually repairable", () => {
|
||
const release = read(".github/workflows/release.yml");
|
||
const trigger = read(".github/workflows/release-trigger.yml");
|
||
|
||
for (const platform of [
|
||
"macos-arm64",
|
||
"macos-x64",
|
||
"linux-x64",
|
||
"linux-arm64",
|
||
"windows-x64",
|
||
"windows-x86",
|
||
]) {
|
||
assert.match(release, new RegExp(`- ${platform}`));
|
||
}
|
||
assert.match(release, /mozilla-actions\/sccache-action@v0\.0\.10/);
|
||
assert.match(release, /SCCACHE_GHA_ENABLED: "true"/);
|
||
assert.match(release, /navop-cargo-inputs-v1-/);
|
||
assert.match(release, /cache: false/);
|
||
assert.doesNotMatch(release, /release-cargo-[^\n]*github\.run_id/);
|
||
assert.match(release, /No existing release assets found/);
|
||
assert.match(release, /cancel-in-progress: false/);
|
||
assert.match(release, /gh release upload[\s\S]*--clobber/);
|
||
|
||
assert.match(trigger, /tags:[\s\S]*- "v\*"/);
|
||
assert.match(trigger, /gh workflow run release\.yml/);
|
||
assert.match(trigger, /-f platform=all/);
|
||
assert.match(
|
||
release,
|
||
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_arm64,\$windows_x64,\$windows_x86\]"/,
|
||
);
|
||
assert.equal(fs.existsSync(".github/workflows/build-arm-linux.yml"), false);
|
||
});
|
||
|
||
test("Rust workflows share one cache strategy without archiving target", () => {
|
||
const workflows = [
|
||
read(".github/workflows/ci.yml"),
|
||
read(".github/workflows/release.yml"),
|
||
];
|
||
|
||
for (const workflow of workflows) {
|
||
assert.match(workflow, /actions-rust-lang\/setup-rust-toolchain@v1/);
|
||
assert.match(workflow, /cache: false/);
|
||
assert.match(workflow, /mozilla-actions\/sccache-action@v0\.0\.10/);
|
||
assert.match(workflow, /RUSTC_WRAPPER: sccache/);
|
||
assert.match(workflow, /SCCACHE_GHA_ENABLED: "true"/);
|
||
// sccache is only a build accelerator. A transient GitHub release CDN
|
||
// failure while installing it must degrade to an uncached build instead of
|
||
// failing the job, so the step stays best-effort and unsets the wrapper.
|
||
assert.match(
|
||
workflow,
|
||
/uses: mozilla-actions\/sccache-action@v0\.0\.10\s+continue-on-error: true/,
|
||
);
|
||
assert.match(workflow, /steps\.sccache\.outcome/);
|
||
assert.match(workflow, /echo "RUSTC_WRAPPER=" >> "\$GITHUB_ENV"/);
|
||
assert.match(
|
||
workflow,
|
||
/key: navop-cargo-inputs-v1-\$\{\{ runner\.os \}\}-\$\{\{ hashFiles\('\*\*\/Cargo\.lock'\) \}\}/,
|
||
);
|
||
assert.doesNotMatch(workflow, /^\s+target\/$/m);
|
||
}
|
||
|
||
const ci = workflows[0];
|
||
assert.match(ci, /branches:\s*[\s\S]*?- main/);
|
||
assert.doesNotMatch(ci, /branches:\s*[\s\S]*?- dev/);
|
||
assert.doesNotMatch(ci, /^\s+tags:/m);
|
||
assert.match(ci, /x86_64-unknown-linux-gnu/);
|
||
assert.match(ci, /x86_64-pc-windows-msvc/);
|
||
assert.doesNotMatch(ci, /key: test-cargo-/);
|
||
|
||
const release = workflows[1];
|
||
assert.doesNotMatch(release, /key: release-cargo-inputs-/);
|
||
assert.match(release, /actions\/cache\/restore@v4/);
|
||
assert.match(release, /actions\/cache\/save@v4/);
|
||
assert.match(release, /cache-primary-key/);
|
||
assert.match(release, /needs\.prepare\.outputs\.platform != 'all'/);
|
||
});
|
||
|
||
test("application updates prefer navop while accepting legacy package names", () => {
|
||
const install = read("main/src/update/install.rs");
|
||
|
||
assert.match(install, /\["navop\.exe", "onetcli\.exe"\]/);
|
||
assert.match(install, /find_file_named\(staging_dir, name\)/);
|
||
assert.match(
|
||
install,
|
||
/\["usr\/bin\/navop", "navop", "usr\/bin\/onetcli", "onetcli"\]/,
|
||
);
|
||
});
|
||
|
||
test("Windows 产物由宿主自己内嵌 Common-Controls v6 清单", () => {
|
||
const build = read("main/build.rs");
|
||
const manifest = read("resources/windows/navop.manifest.xml");
|
||
const release = read(".github/workflows/release.yml");
|
||
|
||
// 清单内容:gpui 的 Windows 层静态导入 comctl32!TaskDialogIndirect,缺了这条依赖声明
|
||
// 加载器只会给进程挂 System32 里的 comctl32 5.82(没有该导出),navop.exe 会在 main
|
||
// 之前以 0xC0000139 退出。
|
||
assert.match(manifest, /name="Microsoft\.Windows\.Common-Controls"/);
|
||
assert.match(manifest, /version="6\.0\.0\.0"/);
|
||
assert.match(manifest, /publicKeyToken="6595b64144ccf1df"/);
|
||
assert.match(manifest, /level="asInvoker"/);
|
||
assert.match(manifest, /supportedOS/);
|
||
// DPI / 堆设置不能省:gpui 的 Windows 层从不调用 SetProcessDpiAwareness(清单是 DPI 感知的
|
||
// 唯一来源),而 v0.20.0 是从 gpui 自带的清单里拿到 PerMonitorV2 + SegmentHeap 的。这里漏了
|
||
// 会让修复后的进程退回 DPI-unaware —— 高 DPI 下整窗被系统位图拉伸(模糊),
|
||
// 等于修好启动又引入一个新问题。
|
||
assert.match(manifest, /<dpiAware xmlns="[^"]*">true\/pm<\/dpiAware>/);
|
||
assert.match(
|
||
manifest,
|
||
/<dpiAwareness xmlns="[^"]*">PerMonitorV2<\/dpiAwareness>/,
|
||
);
|
||
assert.match(manifest, /<heapType xmlns="[^"]*">SegmentHeap<\/heapType>/);
|
||
// 不能带 XML 声明:winresource 按 RC 字符串逐行写入清单(每行前后各有一个空格),
|
||
// 声明前出现空白不是合法的 XML prolog。上游 Zed 的 gpui.manifest.xml 同样没有声明。
|
||
// (属性用双引号,上游那份用单引号,两者都是合法 XML;本文件的双引号路径是实测过的:
|
||
// winresource 的 escape_string 会把 `"` 写成 `""`,rc 能正确还原。)
|
||
assert.doesNotMatch(manifest, /<\?xml/);
|
||
|
||
// 上游 gpui-fast 只在宿主没声明「我自己提供清单」时才嵌(gpui-kit 声明了
|
||
// windows-manifest-provided-by-host,而 Cargo 特性只做并集、下游关不掉),
|
||
// 所以这份清单只能由 navop 自己嵌。
|
||
assert.match(
|
||
build,
|
||
/set_manifest\(include_str!\("\.\.\/resources\/windows\/navop\.manifest\.xml"\)\)/,
|
||
);
|
||
// winresource 只在 VersionInfo::FILETYPE 存在时才写清单块:漏了这行会静默不嵌。
|
||
assert.match(build, /set_version_info\(winresource::VersionInfo::FILETYPE/);
|
||
assert.match(
|
||
build,
|
||
/rerun-if-changed=\.\.\/resources\/windows\/navop\.manifest\.xml/,
|
||
);
|
||
|
||
// 发布流程必须在打包前校验真实产物(静态检查拦不住「清单被上游吃掉」这类回归)。
|
||
const validateStep = workflowStep(release, "Validate Windows executable manifest");
|
||
assert.match(validateStep, /node script\/validate-windows-exe\.mjs/);
|
||
assert.match(validateStep, /\$\{\{ matrix\.target \}\}\/release\/navop\.exe/);
|
||
assert.ok(
|
||
release.indexOf("- name: Validate Windows executable manifest") >
|
||
release.indexOf("- name: Build release binary (Windows)"),
|
||
"manifest validation must run after the Windows binary is built",
|
||
);
|
||
assert.ok(
|
||
release.indexOf("- name: Validate Windows executable manifest") <
|
||
release.indexOf("- name: Package (Windows)"),
|
||
"manifest validation must run before the binary is packaged",
|
||
);
|
||
});
|
||
|
||
test("Windows 清单门禁能识别缺失清单的 PE(含真实失败现象的判据)", () => {
|
||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "navop-manifest-"));
|
||
const goodExe = path.join(temporary, "good.exe");
|
||
const badExe = path.join(temporary, "bad.exe");
|
||
fs.writeFileSync(
|
||
goodExe,
|
||
peFixtureWithResource(read("resources/windows/navop.manifest.xml")),
|
||
);
|
||
// 真实坏产物的等价物:静态导入了 TaskDialogIndirect,但没有任何清单。
|
||
fs.writeFileSync(badExe, peFixtureWithResource("TaskDialogIndirect"));
|
||
|
||
try {
|
||
const ok = spawnSync(
|
||
process.execPath,
|
||
["script/validate-windows-exe.mjs", goodExe],
|
||
{ encoding: "utf8" },
|
||
);
|
||
assert.equal(ok.status, 0, ok.stderr || ok.stdout);
|
||
|
||
const bad = spawnSync(
|
||
process.execPath,
|
||
["script/validate-windows-exe.mjs", badExe],
|
||
{ encoding: "utf8" },
|
||
);
|
||
assert.equal(bad.status, 1, bad.stdout);
|
||
assert.match(bad.stderr, /Microsoft\.Windows\.Common-Controls/);
|
||
assert.match(bad.stderr, /TaskDialogIndirect/);
|
||
assert.match(bad.stderr, /0xC0000139/);
|
||
|
||
// 非 PE 输入也要报错而不是崩在解析上。
|
||
const notPe = path.join(temporary, "not-pe.exe");
|
||
fs.writeFileSync(notPe, "not a PE file");
|
||
const junk = spawnSync(
|
||
process.execPath,
|
||
["script/validate-windows-exe.mjs", notPe],
|
||
{ encoding: "utf8" },
|
||
);
|
||
assert.equal(junk.status, 1);
|
||
assert.match(junk.stderr, /不是 PE 文件/);
|
||
} finally {
|
||
fs.rmSync(temporary, { recursive: true, force: true });
|
||
}
|
||
});
|