mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
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:
1 parent
ee2a6a766f
commit
717497e25d
16 files changed
+503
-399
No files matched your search
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user