diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0945ff7..caa07fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,17 +86,9 @@ jobs: xvfb - name: Install Tauri WebDriver - if: runner.os != 'macOS' + if: runner.os == 'Linux' run: cargo install tauri-driver --locked - - name: Install Microsoft Edge WebDriver - if: runner.os == 'Windows' - shell: pwsh - run: | - cargo install --git https://github.com/chippers/msedgedriver-tool - msedgedriver-tool - $PWD.Path | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Install backend (with packaging extras) run: | python -m pip install --upgrade pip @@ -163,7 +155,7 @@ jobs: DESKTOP_APP_PATH: ${{ steps.desktop-install.outputs.app_path }} ZVEC_PORT: "17862" ZVEC_STUDIO_DATA_DIR: ${{ github.workspace }}/artifacts/desktop-smoke/wdio-data - run: pnpm --filter desktop e2e:installed + run: pnpm --filter desktop e2e:installed:windows - name: Upload desktop smoke diagnostics if: failure() || always() diff --git a/apps/desktop/package.json b/apps/desktop/package.json index aeec315..8bac7fb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,6 +8,7 @@ "tauri:dev": "tauri dev", "tauri:build": "tauri build && bash scripts/post-build-sign.sh", "e2e:installed": "wdio run wdio.installed.conf.mjs", + "e2e:installed:windows": "node tests/installed-app.windows-smoke.mjs", "lint": "echo '(desktop shell uses cargo for lint -- run `make desktop.check`)' && exit 0", "typecheck": "echo '(desktop shell typecheck happens via cargo check)' && exit 0", "test": "echo '(desktop tests run via cargo test -- see `make desktop.test`)' && exit 0", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0393c0f..2a1e435 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -90,6 +90,28 @@ pub fn run() { config: config.clone(), }; + let context = tauri::generate_context!(); + + #[cfg(target_os = "windows")] + let context = { + let mut context = context; + if let Ok(additional_args) = env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS") { + let additional_args = additional_args.trim(); + if !additional_args.is_empty() { + for window in &mut context.config_mut().app.windows { + window.additional_browser_args = + Some(match window.additional_browser_args.take() { + Some(configured_args) => format!("{configured_args} {additional_args}"), + None => format!( + "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection {additional_args}" + ), + }); + } + } + } + context + }; + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .manage(app_state) @@ -97,7 +119,7 @@ pub fn run() { commands::sidecar_status, commands::sidecar_url, ]) - .build(tauri::generate_context!()) + .build(context) .expect("failed to build the Tauri application") .run(|app_handle, event| { let should_shutdown = matches!( diff --git a/apps/desktop/tests/installed-app.windows-smoke.mjs b/apps/desktop/tests/installed-app.windows-smoke.mjs new file mode 100644 index 0000000..ba5b8ed --- /dev/null +++ b/apps/desktop/tests/installed-app.windows-smoke.mjs @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(currentDir, '../../..'); +const frontendRequire = createRequire(path.join(repoRoot, 'apps', 'frontend', 'package.json')); +const { chromium, expect } = frontendRequire('@playwright/test'); +const artifactsDir = path.join(repoRoot, 'artifacts', 'desktop-smoke'); +const appPath = process.env.DESKTOP_APP_PATH; +const tauriConfig = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'apps', 'desktop', 'src-tauri', 'tauri.conf.json'), 'utf8'), +); +const webviewUserDataDir = process.env.LOCALAPPDATA + ? path.win32.join(process.env.LOCALAPPDATA, tauriConfig.identifier, 'EBWebView') + : undefined; +const devToolsActivePortPath = webviewUserDataDir + ? path.win32.join(webviewUserDataDir, 'DevToolsActivePort') + : undefined; +let appProcess; +let browser; + +function waitForCdpEndpoint(timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + function attempt() { + try { + const [portLine] = fs.readFileSync(devToolsActivePortPath, 'utf8').split(/\r?\n/); + const port = Number(portLine); + if (Number.isInteger(port) && port > 0) { + resolve(`http://127.0.0.1:${port}`); + return; + } + } catch (error) { + if (error?.code !== 'ENOENT') { + reject(error); + return; + } + } + if (Date.now() >= deadline) { + reject(new Error(`Timed out waiting for ${devToolsActivePortPath}`)); + return; + } + setTimeout(attempt, 250); + } + attempt(); + }); +} + +function stopApp() { + if (!appProcess || appProcess.exitCode !== null) { + return; + } + + if (process.platform === 'win32') { + spawnSync('taskkill', ['/PID', String(appProcess.pid), '/T', '/F'], { + stdio: 'ignore', + }); + } else { + appProcess.kill('SIGTERM'); + } +} + +async function waitForPage() { + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + const page = browser + .contexts() + .flatMap((context) => context.pages()) + .find((candidate) => candidate.url() !== 'about:blank'); + if (page) { + return page; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error('Timed out waiting for the Tauri WebView2 page'); +} + +async function tauriInvoke(page, command) { + return page.evaluate(async (cmd) => { + const invoke = window.__TAURI_INTERNALS__?.invoke ?? window.__TAURI__?.invoke; + if (!invoke) return null; + return invoke(cmd); + }, command); +} + +async function main() { + if (process.platform !== 'win32') { + throw new Error('The WebView2 CDP smoke test only supports Windows'); + } + if (!appPath || !fs.existsSync(appPath)) { + throw new Error(`DESKTOP_APP_PATH does not exist: ${appPath}`); + } + if (!devToolsActivePortPath) { + throw new Error('LOCALAPPDATA is required for the Windows WebView2 smoke test'); + } + + fs.mkdirSync(artifactsDir, { recursive: true }); + fs.rmSync(devToolsActivePortPath, { force: true }); + const appLog = fs.openSync(path.join(artifactsDir, 'windows-app.log'), 'a'); + const env = { + ...process.env, + ZVEC_HOST: process.env.ZVEC_HOST ?? '127.0.0.1', + ZVEC_PORT: process.env.ZVEC_PORT ?? '17862', + ZVEC_READY_TIMEOUT_SECS: process.env.ZVEC_READY_TIMEOUT_SECS ?? '60', + ZVEC_STUDIO_DATA_DIR: + process.env.ZVEC_STUDIO_DATA_DIR ?? path.join(artifactsDir, 'windows-data'), + WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: '--remote-debugging-port=0', + }; + + appProcess = spawn(appPath, [], { + env, + stdio: ['ignore', appLog, appLog], + }); + appProcess.once('exit', (code, signal) => { + if (code && code !== 0) { + console.error(`installed app exited with code ${code}`); + } + if (signal) { + console.error(`installed app exited via signal ${signal}`); + } + }); + + const cdpUrl = await waitForCdpEndpoint(60000); + browser = await chromium.connectOverCDP(cdpUrl); + const page = await waitForPage(); + await expect(page.getByTestId('app-shell')).toBeVisible({ timeout: 60000 }); + + await expect + .poll(async () => (await tauriInvoke(page, 'sidecar_status'))?.running, { + timeout: 60000, + message: 'sidecar_status did not report a running sidecar', + }) + .toBe(true); + const status = await tauriInvoke(page, 'sidecar_status'); + assert.equal(status.running, true); + assert.match(status.base_url, /^http:\/\/127\.0\.0\.1:\d+$/); + + const suffix = Date.now().toString(36); + const collectionName = `uitest${suffix}`; + const collectionPath = path.join(os.tmpdir(), `zvec-${collectionName}`); + + await page.getByTestId('zv-welcome-create').click(); + await page.getByTestId('zv-create-name').fill(collectionName); + await page.getByTestId('zv-create-path').fill(collectionPath); + await page.getByTestId('zv-create-submit').click(); + + await expect(page.getByTestId('zv-detail-tab-overview')).toBeVisible({ + timeout: 60000, + }); + assert.equal(new URL(page.url()).pathname, `/collections/${collectionName}`); + + for (const tabName of ['overview', 'browse', 'query', 'write']) { + const tab = page.getByTestId(`zv-detail-tab-${tabName}`); + await tab.click(); + await expect(tab).toHaveClass(/zv-detail-tab--active/, { timeout: 30000 }); + } + + console.log('installed Windows desktop UI smoke: ok'); +} + +try { + await main(); +} finally { + await browser?.close().catch(() => {}); + stopApp(); +} diff --git a/scripts/desktop_api_smoke.py b/scripts/desktop_api_smoke.py index 2a6ca43..0a10c2f 100644 --- a/scripts/desktop_api_smoke.py +++ b/scripts/desktop_api_smoke.py @@ -114,7 +114,12 @@ def _terminate(proc: subprocess.Popen[bytes]) -> None: if proc.poll() is not None: return if os.name == "nt": - proc.terminate() + subprocess.run( + ["taskkill", "/PID", str(proc.pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) else: proc.send_signal(signal.SIGTERM) try: