fix(desktop): externalize WebView2 release smoke (#8333)

Remove test-only WebView2 approval instrumentation from the shipped Desktop executable. Preserve approval behavior coverage in real Chromium and validate the exact Windows production binary externally through UI Automation, including the real composer identity.
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-11 15:43:36 +08:00
1 parent ee2a6a766f
commit 717497e25d
16 files changed
+503 -399

No files matched your search

+12 -6
View File
@@ -365,6 +365,7 @@ jobs:
pnpm --dir frontend test:task-monitor
pnpm --dir frontend test:workspace
pnpm --dir frontend test:motion
pnpm --dir frontend test:motion-browser
pnpm --dir frontend test:transcript
pnpm --dir frontend test:transcript-browser
@@ -472,21 +473,26 @@ jobs:
- name: Test native motion contracts
run: pnpm --dir frontend test:motion
- name: Test WebView2 native smoke state machine
shell: pwsh
run: ../scripts/test-webview2-native-smoke.ps1 -SelfTest
# Build the real Wails executable on every desktop PR and exercise the
# approval interaction in Microsoft's installed WebView2 runtime. The
# frontend and bindings were already built above, so this skips only
# exact production startup path in Microsoft's installed WebView2 runtime.
# The frontend and bindings were already built above, so this skips only
# duplicate build work, not the native shell.
- name: Build Wails executable for WebView2 smoke
- name: Build Wails executable for native startup smoke
timeout-minutes: 10
run: wails build -clean -s -skipbindings -nopackage -platform windows/amd64 -webview2 embed
- name: Smoke-test Wails approval in WebView2
- name: Smoke-test Wails/WebView2 native startup
shell: pwsh
timeout-minutes: 2
run: |
../scripts/test-webview2-approval-smoke.ps1 `
../scripts/test-webview2-native-smoke.ps1 `
-ExecutablePath "build/bin/reasonix-desktop.exe" `
-TimeoutSeconds 60
-TimeoutSeconds 60 `
-HealthySeconds 5
- name: test (Windows desktop and update helper)
timeout-minutes: 15
+7 -6
View File
@@ -405,20 +405,21 @@ jobs:
ref: ${{ github.workflow_sha }}
path: release-control
sparse-checkout: |
scripts/test-webview2-approval-smoke.ps1
scripts/test-webview2-native-smoke.ps1
scripts/verify-windows-authenticode.ps1
sparse-checkout-cone-mode: false
persist-credentials: false
# Exercise the exact Wails binary built for Stable on Microsoft's real
# WebView2 runtime before any signing or publication side effect.
- name: Smoke-test Wails approval in WebView2
# Exercise the exact production Wails binary built for Stable on
# Microsoft's real WebView2 runtime before signing or publication.
- name: Smoke-test Wails/WebView2 native startup
if: matrix.platform == 'windows/amd64'
shell: pwsh
run: |
./release-control/scripts/test-webview2-approval-smoke.ps1 `
./release-control/scripts/test-webview2-native-smoke.ps1 `
-ExecutablePath "desktop/build/bin/reasonix-desktop.exe" `
-TimeoutSeconds 60
-TimeoutSeconds 60 `
-HealthySeconds 5
# Sign every executable that users actually run before rebuilding the
# portable archive and NSIS installer. Signing only the finished NSIS
@@ -0,0 +1,83 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
? path.join(frontendDir, ".pw-browsers")
: process.env.PLAYWRIGHT_BROWSERS_PATH;
const { chromium } = await import("playwright");
const port = Number(process.env.REASONIX_APPROVAL_BROWSER_PORT ?? 4620);
const url = `http://127.0.0.1:${port}/`;
function assert(condition, message) {
if (!condition) throw new Error(message);
process.stdout.write(` PASS ${message}\n`);
}
async function waitForServer() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const ready = await new Promise((resolve) => {
const request = http.get(url, (response) => {
response.resume();
resolve((response.statusCode ?? 500) < 500);
});
request.on("error", () => resolve(false));
});
if (ready) return;
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error("approval browser preview did not become ready");
}
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
cwd: frontendDir,
stdio: "ignore",
});
let browser;
try {
await waitForServer();
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
await page.addInitScript(() => {
window.__reasonixApprovalAnimationCalls = [];
const original = Element.prototype.animate;
Element.prototype.animate = function (frames, options) {
if (this instanceof HTMLElement && this.querySelector(".prompt-shelf")) {
window.__reasonixApprovalAnimationCalls.push({
easing: typeof options === "object" && options ? String(options.easing ?? "") : "",
});
}
return original.call(this, frames, options);
};
});
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
const composer = page.locator("#composer-input");
await composer.waitFor({ state: "visible", timeout: 30_000 });
await composer.fill("/mock-tool-approval");
await page.locator(".composer__btn--send").click();
const action = page.locator(".prompt-shelf__actions .prompt-action").first();
await action.waitFor({ state: "visible", timeout: 30_000 });
await action.click();
await page.locator(".decision-confirm-bar__confirm").click();
await page.waitForFunction(() => !document.querySelector(".prompt-shelf--tool-approval"), undefined, { timeout: 10_000 });
const calls = await page.evaluate(() => window.__reasonixApprovalAnimationCalls ?? []);
assert(calls.length === 1, `approval invokes one native Web Animation (${JSON.stringify(calls)})`);
assert(calls[0]?.easing === "cubic-bezier(0.8, 0, 0.8, 0.28)", `approval uses the CSS easing contract (${calls[0]?.easing})`);
assert(pageErrors.length === 0, `approval interaction completes without page errors (${JSON.stringify(pageErrors)})`);
process.stdout.write("\napproval animation browser gate passed\n");
} finally {
await browser?.close();
preview.kill("SIGTERM");
}
+1
View File
@@ -19,6 +19,7 @@
"test:workspace": "tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-context-menu.test.tsx",
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
"test:transcript": "tsx src/__tests__/transcript-scroll-controller.test.ts && tsx src/__tests__/transcript-scroll-session.test.ts && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/transcript-measurement-invalidation.test.tsx && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-runtime.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx",
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs && PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-scroll-stability.mjs",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -8,6 +8,8 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const workflow = readFileSync(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8");
const packageJSON = JSON.parse(readFileSync(resolve(repoRoot, "desktop/frontend/package.json"), "utf8"));
const appSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/App.tsx"), "utf8");
const bridgeSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/lib/bridge.ts"), "utf8");
const desktopMainSource = readFileSync(resolve(repoRoot, "desktop/main.go"), "utf8");
function jobBody(name, nextName) {
const match = workflow.match(new RegExp(`\\n ${name}:\\n([\\s\\S]*?)\\n ${nextName}:`));
@@ -28,19 +30,40 @@ for (const [job, body, command] of [
const windowsJob = jobBody("desktop-windows", "lint");
for (const required of [
"wails build -clean -s -skipbindings -nopackage -platform windows/amd64 -webview2 embed",
"Smoke-test Wails approval in WebView2",
"../scripts/test-webview2-approval-smoke.ps1",
"Test WebView2 native smoke state machine",
"../scripts/test-webview2-native-smoke.ps1 -SelfTest",
"Smoke-test Wails/WebView2 native startup",
"../scripts/test-webview2-native-smoke.ps1",
]) {
if (!windowsJob.includes(required)) {
throw new Error(`motion-ci-contract: desktop-windows must include ${required}`);
}
}
if (!appSource.includes('lazy(() => import("./lib/useWebView2ApprovalSmoke")')) {
throw new Error("motion-ci-contract: WebView2 smoke instrumentation must stay out of the normal startup bundle");
for (const [path, source] of [
["desktop/main.go", desktopMainSource],
["desktop/frontend/src/App.tsx", appSource],
["desktop/frontend/src/lib/bridge.ts", bridgeSource],
]) {
for (const forbidden of [
"REASONIX_WEBVIEW2_APPROVAL_SMOKE",
"__REASONIX_WEBVIEW2_APPROVAL_SMOKE__",
"WebView2ApprovalSmokeBridge",
]) {
if (source.includes(forbidden)) {
throw new Error(`motion-ci-contract: ${path} must not embed test-only WebView2 instrumentation (${forbidden})`);
}
}
}
if (appSource.includes('from "./lib/useWebView2ApprovalSmoke"')) {
throw new Error("motion-ci-contract: WebView2 smoke instrumentation must not use a static App import");
for (const retiredPath of [
"desktop/webview2_approval_smoke.go",
"desktop/frontend/src/lib/useWebView2ApprovalSmoke.ts",
"desktop/frontend/src/lib/webView2ApprovalSmoke.ts",
"scripts/test-webview2-approval-smoke.ps1",
]) {
if (existsSync(resolve(repoRoot, retiredPath))) {
throw new Error(`motion-ci-contract: retired production smoke path still exists: ${retiredPath}`);
}
}
const motionScript = packageJSON.scripts?.["test:motion"] ?? "";
@@ -58,6 +81,15 @@ if (motionScript.includes("transcript-virtualization.test.tsx")) {
throw new Error("motion-ci-contract: test:motion must not include the transcript virtualization suite");
}
const motionBrowserCommand = "pnpm --dir frontend test:motion-browser";
const motionBrowserRuns = workflow.match(/pnpm --dir frontend test:motion-browser(?:\s|$)/g)?.length ?? 0;
if (!jobBody("desktop", "desktop-macos").includes(motionBrowserCommand) || motionBrowserRuns !== 1) {
throw new Error("motion-ci-contract: the Linux desktop job must run test:motion-browser exactly once");
}
if (!packageJSON.scripts?.["test:motion-browser"]?.includes("approval-animation.mjs")) {
throw new Error("motion-ci-contract: test:motion-browser must exercise the approval animation in real Chromium");
}
const transcriptScript = packageJSON.scripts?.["test:transcript"] ?? "";
for (const required of [
"transcript-scroll-session.test.ts",
@@ -101,4 +133,4 @@ for (const required of ["transcript-selection.mjs", "transcript-scroll-stability
}
}
console.log("motion-ci-contract: required jobs run focused native motion gates, Linux owns transcript virtualization, and Windows runs the real WebView2 smoke");
console.log("motion-ci-contract: browser approval behavior and exact-binary WebView2 startup are separate release gates without production smoke instrumentation");
-2
View File
@@ -54,7 +54,6 @@ import { RuntimeDecisionCard } from "./components/RuntimeDecisionCard";
import { decisionSurfaceMockFromInput, type DecisionSurfaceKind as MockDecisionSurfaceKind } from "./lib/decisionSurfaceMock";
const UndoRewindBanner = lazy(() => import("./components/UndoRewindBanner").then((module) => ({ default: module.UndoRewindBanner })));
const WebView2ApprovalSmoke = lazy(() => import("./lib/useWebView2ApprovalSmoke").then((module) => ({ default: module.WebView2ApprovalSmoke })));
/** Footer decision surface kinds. Runtime blockers are explicit recovery choices. */
type DecisionSurfaceKind = MockDecisionSurfaceKind | "extension_form";
@@ -4296,7 +4295,6 @@ export default function App() {
return (
<ShellExpandProvider>
<UpdaterProvider>
{window.__REASONIX_WEBVIEW2_APPROVAL_SMOKE__ === true && <Suspense fallback={null}><WebView2ApprovalSmoke activeTabId={activeTabId} approval={state.approval} /></Suspense>}
<ShellHotkeys />
<TextSizeHotkeys />
<div
+2 -3
View File
@@ -684,8 +684,8 @@ interface WailsRuntime {
declare global {
interface Window {
runtime?: WailsRuntime; __REASONIX_WEBVIEW2_APPROVAL_SMOKE__?: boolean;
go?: { main?: { App?: AppBindings; WebView2ApprovalSmokeBridge?: { Complete(ok: boolean, detail: string): Promise<void> } } };
runtime?: WailsRuntime;
go?: { main?: { App?: AppBindings } };
}
}
@@ -702,7 +702,6 @@ const WAILS_IPC_NULL_SEND_RE = /Cannot read properties of null \(reading 'send'\
// once would pin the browser mock for the whole session (and show fake data — the
// dev mock's model list leaking into the real app was exactly this bug).
function realApp(): AppBindings | undefined {
if (typeof window !== "undefined" && window.__REASONIX_WEBVIEW2_APPROVAL_SMOKE__ === true) return undefined;
return typeof window !== "undefined" ? window.go?.main?.App : undefined;
}
@@ -1,94 +0,0 @@
import { useEffect, useRef } from "react";
import { app } from "./bridge";
import type { WireApproval } from "./types";
import { webView2ApprovalSmokeEnabled } from "./webView2ApprovalSmoke";
type NativeAnimate = typeof Element.prototype.animate;
export function WebView2ApprovalSmoke({
activeTabId,
approval,
}: {
activeTabId: string | undefined;
approval: WireApproval | undefined;
}) {
useWebView2ApprovalSmoke(activeTabId, approval);
return null;
}
export function useWebView2ApprovalSmoke(activeTabId: string | undefined, approval: WireApproval | undefined) {
const started = useRef(false);
const sawApproval = useRef(false);
const reported = useRef(false);
const nativeCalls = useRef(0);
const nativeEasing = useRef("");
const report = (ok: boolean, detail: string) => {
if (reported.current) return;
reported.current = true;
const complete = window.go?.main?.WebView2ApprovalSmokeBridge?.Complete;
if (typeof complete === "function") void complete(ok, detail.slice(0, 240));
};
useEffect(() => {
if (!webView2ApprovalSmokeEnabled()) return;
const original = Element.prototype.animate as NativeAnimate | undefined;
if (typeof original !== "function") {
report(false, "Element.animate is unavailable in WebView2");
return;
}
const onError = () => report(false, "window error during approval smoke");
const onRejection = () => report(false, "unhandled rejection during approval smoke");
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
Element.prototype.animate = function (frames, options) {
if (this instanceof HTMLElement && this.querySelector(".prompt-shelf")) {
nativeCalls.current += 1;
nativeEasing.current = typeof options === "object" && options ? String(options.easing ?? "") : "";
if (!nativeEasing.current || /power\d?\./i.test(nativeEasing.current)) {
report(false, `approval used invalid easing: ${nativeEasing.current || "<empty>"}`);
}
}
return original.call(this, frames, options);
};
return () => {
Element.prototype.animate = original;
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}, []);
useEffect(() => {
if (!webView2ApprovalSmokeEnabled() || !activeTabId || started.current) return;
started.current = true;
void app.SubmitToTab(activeTabId, "/mock-tool-approval").catch(() => {
report(false, "mock approval submit failed");
});
}, [activeTabId]);
useEffect(() => {
if (!webView2ApprovalSmokeEnabled() || !approval || sawApproval.current) return;
sawApproval.current = true;
window.setTimeout(() => {
const action = document.querySelector<HTMLButtonElement>(".prompt-shelf__actions .prompt-action");
const confirm = document.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm");
if (!action || !confirm) {
report(false, "approval controls did not render");
return;
}
action.click();
confirm.click();
}, 0);
}, [approval]);
useEffect(() => {
if (!webView2ApprovalSmokeEnabled() || !sawApproval.current || approval || reported.current) return;
window.setTimeout(() => {
if (nativeCalls.current !== 1) {
report(false, `approval native animation count was ${nativeCalls.current}, expected 1`);
return;
}
report(true, `approval completed with native easing ${nativeEasing.current}`);
}, 0);
}, [approval]);
}
@@ -1,9 +0,0 @@
declare global {
interface Window {
__REASONIX_WEBVIEW2_APPROVAL_SMOKE__?: boolean;
}
}
export function webView2ApprovalSmokeEnabled(): boolean {
return typeof window !== "undefined" && window.__REASONIX_WEBVIEW2_APPROVAL_SMOKE__ === true;
}
+8 -10
View File
@@ -99,7 +99,8 @@ func main() {
if handled, exitCode := maybeRunMacUpdateHandoff(os.Args[1:]); handled {
os.Exit(exitCode)
}
webView2ApprovalSmoke := prepareWebView2ApprovalSmoke()
capturePreviousFatalCrash()
installFatalCrashOutput()
launch := parseDesktopLaunchArgs(os.Args[1:])
@@ -108,7 +109,7 @@ func main() {
singleInstance := singleInstanceLock(app)
appMenu := app.createAppMenu()
dragAndDrop := &options.DragAndDrop{EnableFileDrop: true}
bindings := []any{app, &WebView2ApprovalSmokeBridge{app: app}}
bindings := []any{app}
if launch.RemoteWindowTicket != "" {
// A remote web child window: a second Reasonix process that hosts the
@@ -130,7 +131,7 @@ func main() {
appMenu = nil
dragAndDrop = &options.DragAndDrop{DisableWebViewDrop: true}
bindings = nil
} else if !webView2ApprovalSmoke {
} else {
// Observe previous run for crash diagnostics only. Startup tracking must
// never force Safe Mode, disable plugins, or select a previous binary.
app.previousRun = repair.NewStartupTracker("").ObservePreviousRun()
@@ -149,7 +150,6 @@ func main() {
// Other platforms provide a no-op implementation.
scheduleWebKitSignalHandlerRepair()
onStartup, onDomReady, onBeforeClose, onShutdown := app.webView2ApprovalSmokeLifecycle()
err := wails.Run(&options.App{
Title: title,
Width: width,
@@ -164,7 +164,6 @@ func main() {
AssetServer: &assetserver.Options{
Assets: assets,
Middleware: assetserver.ChainMiddleware(
app.webView2ApprovalSmokeMiddleware(),
app.remoteWindowAssetMiddleware(),
app.jsProfilingMiddleware(),
app.remoteMarkdownImageMiddleware(),
@@ -172,10 +171,10 @@ func main() {
app.themeAssetMiddleware(),
),
},
OnStartup: onStartup,
OnDomReady: onDomReady,
OnBeforeClose: onBeforeClose,
OnShutdown: onShutdown,
OnStartup: app.startup,
OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose,
OnShutdown: app.shutdown,
Bind: bindings,
SingleInstanceLock: singleInstance,
@@ -221,7 +220,6 @@ func main() {
if err != nil {
println("Error:", err.Error())
}
finishWebView2ApprovalSmokeProcess(err)
}
// desktopLaunchOptions captures legacy argv that old installers/shortcuts may
-160
View File
@@ -1,160 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"os"
goruntime "runtime"
"strings"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
const webView2ApprovalSmokeEnv = "REASONIX_WEBVIEW2_APPROVAL_SMOKE"
var webView2ApprovalSmokeScript = []byte(`<script>window.__REASONIX_WEBVIEW2_APPROVAL_SMOKE__=true;</script>`)
type webView2ApprovalSmokeState struct {
enabled bool
done atomic.Bool
passed atomic.Bool
}
var activeWebView2ApprovalSmoke webView2ApprovalSmokeState
type WebView2ApprovalSmokeBridge struct {
app *App
}
func webView2ApprovalSmokeRequested() bool {
return webView2ApprovalSmokeRequestedFor(goruntime.GOOS, os.Getenv(webView2ApprovalSmokeEnv))
}
func webView2ApprovalSmokeRequestedFor(goos, value string) bool {
return goos == "windows" && strings.TrimSpace(value) == "1"
}
func prepareWebView2ApprovalSmoke() bool {
enabled := webView2ApprovalSmokeRequested()
activeWebView2ApprovalSmoke.enabled = enabled
if !enabled {
capturePreviousFatalCrash()
installFatalCrashOutput()
}
return enabled
}
func (a *App) webView2ApprovalSmokeLifecycle() (
func(context.Context),
func(context.Context),
func(context.Context) bool,
func(context.Context),
) {
if !activeWebView2ApprovalSmoke.enabled {
return a.startup, a.domReady, a.beforeClose, a.shutdown
}
return a.startWebView2ApprovalSmoke, a.domReadyWebView2ApprovalSmoke, nil, nil
}
func (a *App) startWebView2ApprovalSmoke(ctx context.Context) {
a.ctx = ctx
go func() {
timer := time.NewTimer(45 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
a.finishWebView2ApprovalSmoke(false, "timed out waiting for approval interaction")
case <-ctx.Done():
}
}()
}
func (a *App) domReadyWebView2ApprovalSmoke(context.Context) {
runtime.WindowCenter(a.ctx)
runtime.WindowShow(a.ctx)
}
// Complete is inert outside the release smoke path. The production frontend
// never calls the bridge because the injected smoke marker is absent.
func (b *WebView2ApprovalSmokeBridge) Complete(ok bool, detail string) error {
if !activeWebView2ApprovalSmoke.enabled || b.app == nil {
return errors.New("WebView2 approval smoke is not enabled")
}
b.app.finishWebView2ApprovalSmoke(ok, detail)
return nil
}
func (a *App) finishWebView2ApprovalSmoke(ok bool, detail string) {
if !activeWebView2ApprovalSmoke.done.CompareAndSwap(false, true) {
return
}
activeWebView2ApprovalSmoke.passed.Store(ok)
if ok {
slog.Info("WebView2 approval smoke passed", "detail", detail)
} else {
slog.Error("WebView2 approval smoke failed", "detail", detail)
}
runtime.Quit(a.ctx)
}
type smokeAssetResponse struct {
header http.Header
status int
body bytes.Buffer
}
func (w *smokeAssetResponse) Header() http.Header { return w.header }
func (w *smokeAssetResponse) WriteHeader(status int) {
if w.status == 0 {
w.status = status
}
}
func (w *smokeAssetResponse) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.body.Write(p)
}
func (a *App) webView2ApprovalSmokeMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if !activeWebView2ApprovalSmoke.enabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || (r.URL.Path != "/" && r.URL.Path != "/index.html") {
next.ServeHTTP(w, r)
return
}
capture := &smokeAssetResponse{header: make(http.Header)}
next.ServeHTTP(capture, r)
body := capture.body.Bytes()
if bytes.Contains(body, []byte("<head>")) {
body = bytes.Replace(body, []byte("<head>"), append([]byte("<head>"), webView2ApprovalSmokeScript...), 1)
}
for key, values := range capture.header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.Header().Del("Content-Length")
status := capture.status
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
_, _ = w.Write(body)
})
}
}
func finishWebView2ApprovalSmokeProcess(err error) {
if activeWebView2ApprovalSmoke.enabled && (err != nil || !activeWebView2ApprovalSmoke.passed.Load()) {
os.Exit(1)
}
}
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWebView2ApprovalSmokeMiddleware(t *testing.T) {
activeWebView2ApprovalSmoke.enabled = true
t.Cleanup(func() { activeWebView2ApprovalSmoke.enabled = false })
app := &App{}
handler := app.webView2ApprovalSmokeMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<!doctype html><html><head></head><body></body></html>"))
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
if !strings.Contains(recorder.Body.String(), "__REASONIX_WEBVIEW2_APPROVAL_SMOKE__=true") {
t.Fatal("smoke marker was not injected before the frontend bundle")
}
}
func TestWebView2ApprovalSmokeMiddlewareDisabled(t *testing.T) {
app := &App{}
handler := app.webView2ApprovalSmokeMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<head></head>"))
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
if strings.Contains(recorder.Body.String(), "REASONIX_WEBVIEW2_APPROVAL_SMOKE") {
t.Fatal("ordinary desktop responses must not expose the release smoke marker")
}
}
func TestWebView2ApprovalSmokeRequiresWindowsAndExplicitOptIn(t *testing.T) {
tests := []struct {
name string
goos string
value string
want bool
}{
{name: "Windows enabled", goos: "windows", value: "1", want: true},
{name: "Windows whitespace", goos: "windows", value: " 1 ", want: true},
{name: "Windows disabled", goos: "windows", value: "0", want: false},
{name: "macOS cannot impersonate WebView2", goos: "darwin", value: "1", want: false},
{name: "Linux cannot impersonate WebView2", goos: "linux", value: "1", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := webView2ApprovalSmokeRequestedFor(test.goos, test.value); got != test.want {
t.Fatalf("webView2ApprovalSmokeRequestedFor(%q, %q) = %v, want %v", test.goos, test.value, got, test.want)
}
})
}
}
+49 -2
View File
@@ -46,12 +46,55 @@ func parseSignPathConfiguration(t *testing.T, name string) signPathArtifactConfi
return config
}
func TestWindowsWebView2SmokeUsesExternalProductionBinaryContract(t *testing.T) {
script := readTestFile(t, "../scripts/test-webview2-native-smoke.ps1")
for _, want := range []string{
`Resolve-Path $ExecutablePath`,
`$env:REASONIX_HOME = $smokeHome`,
`$env:REASONIX_STATE_HOME = $smokeState`,
`$env:REASONIX_CACHE_HOME = $smokeCache`,
`close_behavior = "quit"`,
`$Process.MainWindowHandle`,
`$_.Name -ieq "msedgewebview2.exe"`,
`$_.CommandLine -match "--type=renderer"`,
`UIAutomationClient`,
`[System.Windows.Automation.ControlType]::Document`,
`[System.Windows.Automation.ControlType]::Edit`,
`[System.Windows.Automation.AutomationElement]::AutomationIdProperty`,
`"composer-input"`,
`[System.Windows.Automation.AndCondition]::new`,
`[System.Windows.Automation.Condition[]]@($composerTypeCondition, $composerIdCondition)`,
`$root.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $composerCondition)`,
`Update-NativeSmokeStability`,
`a transient renderer handoff must reset without failing`,
`$HealthySeconds consecutive seconds`,
`$process.CloseMainWindow()`,
`$process.WaitForExit(10000)`,
`taskkill.exe /PID $process.Id /T /F`,
} {
if !strings.Contains(script, want) {
t.Errorf("Windows WebView2 smoke is missing production-binary contract %q", want)
}
}
for _, forbidden := range []string{
"REASONIX_WEBVIEW2_APPROVAL_SMOKE",
"mock-tool-approval",
"Element.prototype.animate",
"WebView2ApprovalSmokeBridge",
"Reasonix lost its main window or WebView2 renderer during the health window",
} {
if strings.Contains(script, forbidden) {
t.Errorf("Windows WebView2 smoke still contains production instrumentation %q", forbidden)
}
}
}
func TestWindowsReleaseSignsPayloadBeforeRepackaging(t *testing.T) {
workflow := readTestFile(t, "../.github/workflows/release-desktop.yml")
orderedSteps := []string{
"name: Build and package",
"name: Checkout protected release verifier",
"name: Smoke-test Wails approval in WebView2",
"name: Smoke-test Wails/WebView2 native startup",
"name: Upload unsigned Windows payload for SignPath",
"name: Submit Windows payload for Authenticode signing",
"name: Approve and download signed Windows payload",
@@ -97,13 +140,17 @@ func TestWindowsReleaseSignsPayloadBeforeRepackaging(t *testing.T) {
`REASONIX_REQUIRE_PAYLOAD_MANIFEST: "1"`,
`ref: ${{ github.workflow_sha }}`,
`path: release-control`,
`./release-control/scripts/test-webview2-approval-smoke.ps1`,
`./release-control/scripts/test-webview2-native-smoke.ps1`,
`./release-control/scripts/verify-windows-authenticode.ps1`,
} {
if !strings.Contains(workflow, want) {
t.Errorf("desktop release workflow is missing signing contract %q", want)
}
}
ciWorkflow := readTestFile(t, "../.github/workflows/ci.yml")
if !strings.Contains(ciWorkflow, `../scripts/test-webview2-native-smoke.ps1 -SelfTest`) {
t.Error("Windows CI must run the deterministic native smoke state-machine self-test")
}
for _, forbidden := range []string{
`signing-policy-slug: test-signing`,
`artifact-configuration-slug: windows-installer-test-v2`,
+11 -8
View File
@@ -108,13 +108,16 @@ fi
grep -Eq '^ resolve:$' "$repo_root/.github/workflows/release-desktop.yml"
grep -Eq 'sha:.*steps\.candidate\.outputs\.sha' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'bash scripts/resolve-desktop-candidate.sh' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'name: Smoke-test Wails approval in WebView2' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'name: Smoke-test Wails/WebView2 native startup' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq "if: matrix.platform == 'windows/amd64'" "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq './release-control/scripts/test-webview2-approval-smoke.ps1' "$repo_root/.github/workflows/release-desktop.yml"
test -f "$repo_root/scripts/test-webview2-approval-smoke.ps1"
grep -Fq 'name: Build Wails executable for WebView2 smoke' "$repo_root/.github/workflows/ci.yml"
grep -Fq 'name: Smoke-test Wails approval in WebView2' "$repo_root/.github/workflows/ci.yml"
grep -Fq '../scripts/test-webview2-approval-smoke.ps1' "$repo_root/.github/workflows/ci.yml"
grep -Fq './release-control/scripts/test-webview2-native-smoke.ps1' "$repo_root/.github/workflows/release-desktop.yml"
test -f "$repo_root/scripts/test-webview2-native-smoke.ps1"
test ! -e "$repo_root/scripts/test-webview2-approval-smoke.ps1"
grep -Fq 'name: Build Wails executable for native startup smoke' "$repo_root/.github/workflows/ci.yml"
grep -Fq 'name: Test WebView2 native smoke state machine' "$repo_root/.github/workflows/ci.yml"
grep -Fq '../scripts/test-webview2-native-smoke.ps1 -SelfTest' "$repo_root/.github/workflows/ci.yml"
grep -Fq 'name: Smoke-test Wails/WebView2 native startup' "$repo_root/.github/workflows/ci.yml"
grep -Fq '../scripts/test-webview2-native-smoke.ps1' "$repo_root/.github/workflows/ci.yml"
grep -Fq 'wails build -clean -s -skipbindings -nopackage -platform windows/amd64 -webview2 embed' \
"$repo_root/.github/workflows/ci.yml"
for retired_review_gate in \
@@ -130,7 +133,7 @@ done
! grep -Fq 'cross-boundary-review' "$repo_root/.github/workflows/ci.yml"
! grep -Fq 'independent cross-boundary review' "$repo_root/.github/pull_request_template.md"
desktop_build_line="$(grep -n -m1 'name: Build and package' "$repo_root/.github/workflows/release-desktop.yml" | cut -d: -f1)"
webview2_smoke_line="$(grep -n -m1 'name: Smoke-test Wails approval in WebView2' "$repo_root/.github/workflows/release-desktop.yml" | cut -d: -f1)"
webview2_smoke_line="$(grep -n -m1 'name: Smoke-test Wails/WebView2 native startup' "$repo_root/.github/workflows/release-desktop.yml" | cut -d: -f1)"
signpath_upload_line="$(grep -n -m1 'name: Upload unsigned Windows payload for SignPath' "$repo_root/.github/workflows/release-desktop.yml" | cut -d: -f1)"
[ "$desktop_build_line" -lt "$webview2_smoke_line" ]
[ "$webview2_smoke_line" -lt "$signpath_upload_line" ]
@@ -139,7 +142,7 @@ signpath_upload_line="$(grep -n -m1 'name: Upload unsigned Windows payload for S
[ "$(grep -Fc 'ref: ${{ needs.resolve.outputs.sha }}' "$repo_root/.github/workflows/release-desktop.yml")" -ge 4 ]
[ "$(grep -Ec '^ path: release-control$' "$repo_root/.github/workflows/release-desktop.yml")" = "3" ]
grep -Fq 'name: Checkout protected release verifier' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'scripts/test-webview2-approval-smoke.ps1' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq 'scripts/test-webview2-native-smoke.ps1' "$repo_root/.github/workflows/release-desktop.yml"
grep -Fq './release-control/scripts/verify-windows-authenticode.ps1' "$repo_root/.github/workflows/release-desktop.yml"
[ "$(grep -Fc 'ref: ${{ github.workflow_sha }}' "$repo_root/.github/workflows/release-desktop.yml")" -ge 2 ]
[ "$(grep -Fc 'bash release-control/scripts/resolve-desktop-candidate.sh' "$repo_root/.github/workflows/release-desktop.yml")" = "2" ]
-34
View File
@@ -1,34 +0,0 @@
param(
[Parameter(Mandatory = $true)]
[string]$ExecutablePath,
[int]$TimeoutSeconds = 60
)
$ErrorActionPreference = "Stop"
$exe = (Resolve-Path $ExecutablePath).Path
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() }
$smokeHome = Join-Path $tempRoot ("reasonix-webview2-approval-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $smokeHome | Out-Null
$oldSmoke = $env:REASONIX_WEBVIEW2_APPROVAL_SMOKE
$oldHome = $env:REASONIX_HOME
try {
$env:REASONIX_WEBVIEW2_APPROVAL_SMOKE = "1"
$env:REASONIX_HOME = $smokeHome
$process = Start-Process -FilePath $exe -WorkingDirectory (Split-Path $exe) -PassThru
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
throw "Wails/WebView2 approval smoke timed out after $TimeoutSeconds seconds"
}
if ($process.ExitCode -ne 0) {
throw "Wails/WebView2 approval smoke exited with code $($process.ExitCode)"
}
Write-Host "Wails/WebView2 approval smoke passed"
}
finally {
$env:REASONIX_WEBVIEW2_APPROVAL_SMOKE = $oldSmoke
$env:REASONIX_HOME = $oldHome
if (Test-Path $smokeHome) {
Remove-Item -LiteralPath $smokeHome -Recurse -Force
}
}
+290
View File
@@ -0,0 +1,290 @@
[CmdletBinding(DefaultParameterSetName = "Run")]
param(
[Parameter(Mandatory = $true, ParameterSetName = "Run")]
[string]$ExecutablePath,
[Parameter(Mandatory = $true, ParameterSetName = "SelfTest")]
[switch]$SelfTest,
[Parameter(ParameterSetName = "Run")]
[int]$TimeoutSeconds = 60,
[Parameter(ParameterSetName = "Run")]
[int]$HealthySeconds = 5
)
$ErrorActionPreference = "Stop"
function Get-DescendantProcesses {
param([int]$RootProcessId)
$snapshot = @(Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, Name, CommandLine)
$pending = @([uint32]$RootProcessId)
$seen = @{}
$descendants = @()
while ($pending.Count -gt 0) {
$parentProcessId = [uint32]$pending[0]
$pending = if ($pending.Count -gt 1) { @($pending[1..($pending.Count - 1)]) } else { @() }
foreach ($child in @($snapshot | Where-Object { $_.ParentProcessId -eq $parentProcessId })) {
$childProcessId = [uint32]$child.ProcessId
if ($seen.ContainsKey($childProcessId)) {
continue
}
$seen[$childProcessId] = $true
$descendants += $child
$pending += $childProcessId
}
}
return @($descendants)
}
function Get-WebViewAutomationState {
param([IntPtr]$WindowHandle)
if ($WindowHandle -eq [IntPtr]::Zero) {
return [pscustomobject]@{
DocumentReady = $false
ComposerReady = $false
ErrorType = ""
}
}
try {
$root = [System.Windows.Automation.AutomationElement]::FromHandle($WindowHandle)
if ($null -eq $root) {
return [pscustomobject]@{
DocumentReady = $false
ComposerReady = $false
ErrorType = ""
}
}
$documentCondition = [System.Windows.Automation.PropertyCondition]::new(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Document
)
$composerTypeCondition = [System.Windows.Automation.PropertyCondition]::new(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Edit
)
$composerIdCondition = [System.Windows.Automation.PropertyCondition]::new(
[System.Windows.Automation.AutomationElement]::AutomationIdProperty,
"composer-input"
)
$composerCondition = [System.Windows.Automation.AndCondition]::new(
[System.Windows.Automation.Condition[]]@($composerTypeCondition, $composerIdCondition)
)
return [pscustomobject]@{
DocumentReady = $null -ne $root.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $documentCondition)
ComposerReady = $null -ne $root.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $composerCondition)
ErrorType = ""
}
}
catch {
return [pscustomobject]@{
DocumentReady = $false
ComposerReady = $false
ErrorType = $_.Exception.GetType().Name
}
}
}
function Get-NativeSmokeState {
param([System.Diagnostics.Process]$Process)
$Process.Refresh()
$exited = $Process.HasExited
$windowHandle = if ($exited) { [IntPtr]::Zero } else { $Process.MainWindowHandle }
$descendants = @(Get-DescendantProcesses -RootProcessId $Process.Id)
$renderer = @($descendants | Where-Object {
$_.Name -ieq "msedgewebview2.exe" -and $_.CommandLine -match "--type=renderer"
})
$automation = Get-WebViewAutomationState -WindowHandle $windowHandle
return [pscustomobject]@{
Exited = $exited
WindowHandle = $windowHandle
RendererCount = $renderer.Count
DocumentReady = $automation.DocumentReady
ComposerReady = $automation.ComposerReady
AutomationErrorType = $automation.ErrorType
Descendants = @($descendants | ForEach-Object { "$($_.Name)[$($_.ProcessId)]" })
}
}
function Test-NativeSmokeHealthy {
param([object]$State)
return -not $State.Exited `
-and $State.WindowHandle -ne [IntPtr]::Zero `
-and $State.RendererCount -gt 0 `
-and $State.DocumentReady `
-and $State.ComposerReady
}
function Update-NativeSmokeStability {
param(
[hashtable]$Tracker,
[object]$State,
[DateTime]$Now,
[int]$RequiredHealthySeconds
)
if ($State.Exited) {
return "Exited"
}
if (-not (Test-NativeSmokeHealthy -State $State)) {
$Tracker["HealthySince"] = $null
return "Waiting"
}
if ($null -eq $Tracker["HealthySince"]) {
$Tracker["HealthySince"] = $Now
return "Waiting"
}
if (($Now - $Tracker["HealthySince"]).TotalSeconds -ge $RequiredHealthySeconds) {
return "Ready"
}
return "Waiting"
}
function Format-NativeSmokeState {
param([object]$State)
if ($null -eq $State) {
return "unavailable"
}
$window = "0x{0:X}" -f $State.WindowHandle.ToInt64()
return "exited=$($State.Exited) window=$window renderer=$($State.RendererCount) document=$($State.DocumentReady) composer=$($State.ComposerReady) automation_error=$($State.AutomationErrorType) descendants=$($State.Descendants -join ', ')"
}
function Assert-NativeSmokeSelfTest {
param(
[bool]$Condition,
[string]$Message
)
if (-not $Condition) {
throw "native smoke self-test failed: $Message"
}
}
function New-NativeSmokeSelfTestState {
param(
[bool]$Exited = $false,
[bool]$WindowReady = $true,
[int]$RendererCount = 1,
[bool]$DocumentReady = $true,
[bool]$ComposerReady = $true
)
return [pscustomobject]@{
Exited = $Exited
WindowHandle = if ($WindowReady) { [IntPtr]1 } else { [IntPtr]::Zero }
RendererCount = $RendererCount
DocumentReady = $DocumentReady
ComposerReady = $ComposerReady
}
}
function Invoke-NativeSmokeStateMachineSelfTest {
$start = [DateTime]::Parse("2026-01-01T00:00:00Z").ToUniversalTime()
$tracker = @{ HealthySince = $null }
$healthy = New-NativeSmokeSelfTestState
$missingRenderer = New-NativeSmokeSelfTestState -RendererCount 0
$result = Update-NativeSmokeStability -Tracker $tracker -State $healthy -Now $start -RequiredHealthySeconds 5
Assert-NativeSmokeSelfTest ($result -eq "Waiting") "the first healthy sample must start, not finish, the stability window"
$result = Update-NativeSmokeStability -Tracker $tracker -State $missingRenderer -Now ($start.AddSeconds(4)) -RequiredHealthySeconds 5
Assert-NativeSmokeSelfTest ($result -eq "Waiting" -and $null -eq $tracker["HealthySince"]) "a transient renderer handoff must reset without failing"
$result = Update-NativeSmokeStability -Tracker $tracker -State $healthy -Now ($start.AddSeconds(5)) -RequiredHealthySeconds 5
Assert-NativeSmokeSelfTest ($result -eq "Waiting") "health after a handoff must begin a new stability window"
$result = Update-NativeSmokeStability -Tracker $tracker -State $healthy -Now ($start.AddSeconds(10)) -RequiredHealthySeconds 5
Assert-NativeSmokeSelfTest ($result -eq "Ready") "five consecutive healthy seconds must pass"
foreach ($state in @(
(New-NativeSmokeSelfTestState -WindowReady $false),
(New-NativeSmokeSelfTestState -DocumentReady $false),
(New-NativeSmokeSelfTestState -ComposerReady $false)
)) {
$tracker["HealthySince"] = $start
$result = Update-NativeSmokeStability -Tracker $tracker -State $state -Now ($start.AddSeconds(5)) -RequiredHealthySeconds 5
Assert-NativeSmokeSelfTest ($result -eq "Waiting" -and $null -eq $tracker["HealthySince"]) "window, document, and composer readiness must all be required"
}
$result = Update-NativeSmokeStability -Tracker @{ HealthySince = $null } -State (New-NativeSmokeSelfTestState -Exited $true) -Now $start -RequiredHealthySeconds 5
Assert-NativeSmokeSelfTest ($result -eq "Exited") "process exit must remain terminal"
Write-Host "WebView2 native smoke state-machine self-test passed"
}
if ($SelfTest) {
Invoke-NativeSmokeStateMachineSelfTest
exit 0
}
if ($TimeoutSeconds -le 0 -or $HealthySeconds -le 0 -or $HealthySeconds -ge $TimeoutSeconds) {
throw "TimeoutSeconds must be positive and greater than HealthySeconds"
}
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
$exe = (Resolve-Path $ExecutablePath).Path
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() }
$smokeRoot = Join-Path $tempRoot ("reasonix-webview2-native-" + [guid]::NewGuid().ToString("N"))
$smokeHome = Join-Path $smokeRoot "home"
$smokeState = Join-Path $smokeRoot "state"
$smokeCache = Join-Path $smokeRoot "cache"
New-Item -ItemType Directory -Path $smokeHome, $smokeState, $smokeCache | Out-Null
Set-Content -LiteralPath (Join-Path $smokeHome "config.toml") -Encoding utf8 -Value @"
[desktop]
close_behavior = "quit"
"@
$oldHome = $env:REASONIX_HOME
$oldStateHome = $env:REASONIX_STATE_HOME
$oldCacheHome = $env:REASONIX_CACHE_HOME
$process = $null
try {
$env:REASONIX_HOME = $smokeHome
$env:REASONIX_STATE_HOME = $smokeState
$env:REASONIX_CACHE_HOME = $smokeCache
$process = Start-Process -FilePath $exe -WorkingDirectory (Split-Path $exe) -PassThru
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
$tracker = @{ HealthySince = $null }
$readyState = $null
$lastState = $null
while ([DateTime]::UtcNow -lt $deadline) {
$state = Get-NativeSmokeState -Process $process
$lastState = $state
if ($state.Exited) {
throw "Reasonix exited before the native window became healthy (exit code $($process.ExitCode))"
}
$stability = Update-NativeSmokeStability -Tracker $tracker -State $state -Now ([DateTime]::UtcNow) -RequiredHealthySeconds $HealthySeconds
if ($stability -eq "Ready") {
$readyState = $state
break
}
Start-Sleep -Milliseconds 250
}
if ($null -eq $readyState) {
throw "Reasonix did not keep its main window, WebView2 renderer, document, and composer healthy for $HealthySeconds consecutive seconds within $TimeoutSeconds seconds; last_state=$(Format-NativeSmokeState -State $lastState)"
}
if (-not $process.CloseMainWindow()) {
throw "Reasonix main window rejected the graceful close request"
}
if (-not $process.WaitForExit(10000)) {
throw "Reasonix did not exit within 10 seconds after the graceful close request"
}
if ($process.ExitCode -ne 0) {
throw "Reasonix exited with code $($process.ExitCode) after the graceful close request"
}
Write-Host "Wails/WebView2 native startup smoke passed (window + renderer + document + composer healthy for $HealthySeconds consecutive seconds)"
}
finally {
$env:REASONIX_HOME = $oldHome
$env:REASONIX_STATE_HOME = $oldStateHome
$env:REASONIX_CACHE_HOME = $oldCacheHome
if ($null -ne $process -and -not $process.HasExited) {
& taskkill.exe /PID $process.Id /T /F 2>$null | Out-Null
}
if (Test-Path $smokeRoot) {
Remove-Item -LiteralPath $smokeRoot -Recurse -Force
}
}