From 0d86f199356d1262c6dbc7dfeafb35fac0f552c6 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Tue, 29 Sep 2026 13:04:41 -0400 Subject: [PATCH] ci: Windows smoke test on the windows runner (#19) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .gitea/workflows/windows-smoke.yml | 120 ++++++++++++++++++++ scripts/windows-smoke.mjs | 175 +++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 .gitea/workflows/windows-smoke.yml create mode 100644 scripts/windows-smoke.mjs diff --git a/.gitea/workflows/windows-smoke.yml b/.gitea/workflows/windows-smoke.yml new file mode 100644 index 0000000..ea1546b --- /dev/null +++ b/.gitea/workflows/windows-smoke.yml @@ -0,0 +1,120 @@ +# cinny-desktop #19: smoke-test the Windows app on the `windows` runner. +# +# Installs the NSIS bundle silently, starts the installed app with WebView2's +# DevTools port open, and drives its real page with scripts/windows-smoke.mjs +# (playwright-core over CDP): boots to the login screen, local server address, +# microphone permission for the app and not for a foreign page, the call page's +# own origin + isolation, the Credential Manager round trip. Features a build +# doesn't have are reported "n/a". +# +# Run it from the Actions tab (workflow_dispatch): +# - no `ref`: tests the published nightly installer (a couple of minutes); +# - `ref` = a branch (e.g. a PR branch): builds that branch, then tests it. +name: Windows smoke + +on: + workflow_dispatch: + inputs: + ref: + description: 'Branch to build and test (empty: test the published nightly)' + required: false + default: '' + +env: + GITEA_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + +jobs: + smoke: + runs-on: windows + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + + - name: Download the published nightly installer + if: ${{ inputs.ref == '' }} + shell: powershell + run: | + New-Item -ItemType Directory -Force -Path smoke-installer | Out-Null + Invoke-WebRequest -Uri "$env:GITEA_URL/$env:REPO/releases/download/latest/LotusChat-x86_64-setup.exe" -OutFile smoke-installer\setup.exe + Get-Item smoke-installer\setup.exe | Select-Object Name, Length + + - name: Check out the branch to build + if: ${{ inputs.ref != '' }} + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + path: build-src + + - name: Build the branch + if: ${{ inputs.ref != '' }} + shell: powershell + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' + NODE_OPTIONS: '--max_old_space_size=4096' + CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse + CARGO_HTTP_MULTIPLEXING: 'false' + CARGO_NET_RETRY: '5' + run: | + cd build-src + git submodule update --init --depth=1 + cd cinny; npm ci; cd .. + node scripts/sync-web-config.mjs + npm ci + $env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH" + $toolchain = Get-ChildItem "$env:USERPROFILE\.rustup\toolchains" -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match 'stable' } | Select-Object -First 1 + if ($toolchain) { $env:PATH = "$($toolchain.FullName)\bin;$env:PATH" } + npm run tauri -- build --bundles nsis + New-Item -ItemType Directory -Force -Path ..\smoke-installer | Out-Null + $exe = Get-ChildItem src-tauri\target\release\bundle\nsis\*-setup.exe | Select-Object -First 1 + Copy-Item $exe.FullName ..\smoke-installer\setup.exe + + - name: Install silently + shell: powershell + run: | + Get-Process cinny -ErrorAction SilentlyContinue | Stop-Process -Force + Start-Process smoke-installer\setup.exe -ArgumentList '/S' -Wait + $app = Join-Path $env:LOCALAPPDATA 'Lotus Chat\cinny.exe' + if (-not (Test-Path $app)) { Write-Error "not installed at $app"; exit 1 } + (Get-Item $app).VersionInfo | Select-Object ProductVersion, FileVersion + + - name: Start the app with the WebView2 DevTools port open + shell: powershell + run: | + $env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=9222 --use-fake-device-for-media-stream' + $app = Join-Path $env:LOCALAPPDATA 'Lotus Chat\cinny.exe' + Start-Process $app + Start-Sleep -Seconds 5 + Get-Process cinny | Select-Object Id, SessionId, MainWindowTitle + + - name: Smoke test + shell: powershell + run: | + New-Item -ItemType Directory -Force -Path smoke-deps | Out-Null + Push-Location smoke-deps + npm init -y | Out-Null + npm install --no-audit --no-fund playwright-core@1 | Out-Null + Pop-Location + $env:NODE_PATH = (Resolve-Path smoke-deps\node_modules).Path + Copy-Item scripts\windows-smoke.mjs smoke-deps\windows-smoke.mjs + node smoke-deps\windows-smoke.mjs smoke-out + + - name: Stop the app + if: ${{ always() }} + shell: powershell + run: | + Get-Process cinny -ErrorAction SilentlyContinue | Stop-Process -Force + if (Test-Path smoke-out\results.json) { Get-Content smoke-out\results.json } + + - name: Upload results and screenshots + if: ${{ always() }} + uses: actions/upload-artifact@v3 + with: + name: windows-smoke + path: smoke-out diff --git a/scripts/windows-smoke.mjs b/scripts/windows-smoke.mjs new file mode 100644 index 0000000..a757340 --- /dev/null +++ b/scripts/windows-smoke.mjs @@ -0,0 +1,175 @@ +// cinny-desktop #19: Windows smoke test against the INSTALLED app. +// +// The app is started with WebView2's DevTools port open +// (WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222) and this +// script drives its real page over CDP with playwright-core. No login: every +// check runs on the login screen or through the app's own Tauri commands. +// +// node scripts/windows-smoke.mjs +// +// Checks whose feature isn't in the build under test are reported "n/a" +// instead of failing, so the same script runs on main and on PR branches. +import { writeFileSync, mkdirSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { chromium } from 'playwright-core'; + +const OUT = process.argv[2] || 'smoke-out'; +mkdirSync(OUT, { recursive: true }); +const APP = 'http://localhost:44548'; +const results = []; +const record = (name, status, detail = '') => { + results.push({ name, status, detail }); + console.log(`${status.toUpperCase().padEnd(4)} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +const until = async (fn, ms, step = 500) => { + const end = Date.now() + ms; + for (;;) { + let v; + try { + v = await fn(); + } catch { + v = undefined; + } + if (v || Date.now() > end) return v; + await new Promise((r) => setTimeout(r, step)); + } +}; + +// 1. Connect to the running app's WebView2. +const browser = await until(() => chromium.connectOverCDP('http://127.0.0.1:9222'), 60_000, 1000); +if (!browser) { + record('connect to the app over CDP', 'fail', 'no DevTools endpoint on :9222'); + writeFileSync(`${OUT}/results.json`, JSON.stringify(results, null, 2)); + process.exit(1); +} +const page = await until( + () => browser.contexts().flatMap((c) => c.pages()).find((p) => p.url().startsWith(APP)), + 60_000, +); +if (!page) { + record('app page found', 'fail', browser.contexts().flatMap((c) => c.pages()).map((p) => p.url()).join(', ')); + process.exit(1); +} +record('app page found', 'pass', page.url()); + +// 2. Boots to the login screen. +const booted = await until(async () => /Login|Homeserver/.test(await page.locator('body').innerText()), 60_000); +record('boots to the login screen', booted ? 'pass' : 'fail'); +await page.screenshot({ path: `${OUT}/01-login.png` }); + +// 3. The local server's address (cinny #43 binds 127.0.0.1 explicitly). +try { + const listen = execSync( + 'powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort 44548 -State Listen).LocalAddress -join \',\'"', + ) + .toString() + .trim(); + record('local server listening', listen ? 'pass' : 'fail', listen); +} catch (e) { + record('local server listening', 'fail', String(e).slice(0, 120)); +} + +const cfg = await page.evaluate(() => fetch('/config.json').then((r) => r.json())); + +// 4. Microphone for the app itself (WebView2 PermissionRequested handler, #22). +const appMic = await page.evaluate(async () => { + try { + const s = await navigator.mediaDevices.getUserMedia({ audio: true }); + s.getTracks().forEach((t) => t.stop()); + return 'ok'; + } catch (e) { + return `${e.name}: ${e.message}`; + } +}); +record('microphone allowed for the app', appMic === 'ok' ? 'pass' : 'fail', appMic); + +// 5. Call page on its own origin, isolated from the app (cinny #43 / #27). +if (cfg.desktopCallOrigin) { + const src = `${cfg.desktopCallOrigin}/public/element-call/index.html`; + await page.evaluate((s) => { + const f = document.createElement('iframe'); + f.id = 'smoke-call'; + f.src = s; + f.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;'; + f.sandbox = 'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads'; + document.body.appendChild(f); + }, src); + const frame = await until(() => page.frames().find((f) => f.url().startsWith(src)), 30_000); + if (!frame) { + record('call page loads from its own origin', 'fail', 'frame did not load (CSP?)'); + } else { + await frame.waitForLoadState('domcontentloaded').catch(() => undefined); + const iso = await frame.evaluate(async () => { + const r = { origin: location.origin }; + try { + r.parentStorage = String(parent.localStorage.length); + } catch (e) { + r.parentStorage = e.name; + } + try { + const s = await navigator.mediaDevices.getUserMedia({ audio: true }); + s.getTracks().forEach((t) => t.stop()); + r.mic = 'ok'; + } catch (e) { + r.mic = e.name; + } + return r; + }); + record('call page loads from its own origin', 'pass', iso.origin); + record('call page cannot read the app storage', iso.parentStorage === 'SecurityError' ? 'pass' : 'fail', iso.parentStorage); + record('call page gets the microphone', iso.mic === 'ok' ? 'pass' : 'fail', iso.mic); + } +} else { + record('call page on its own origin', 'n/a', 'desktopCallOrigin not set in this build'); +} + +// 6. OS keychain round trip (cinny #105, step 1). +const kc = await page.evaluate(async () => { + const inv = window.__TAURI_INTERNALS__?.invoke; + if (!inv) return { error: 'no Tauri bridge' }; + try { + const supported = await inv('secure_session_supported'); + if (!supported) return { supported }; + const tokens = { userId: '@smoke:ci.invalid', deviceId: 'SMOKE', accessToken: `smoke-${Date.now()}` }; + const before = await inv('secure_session_get'); + await inv('secure_session_set', { tokens }); + const back = await inv('secure_session_get'); + // Put back whatever was there (nothing, on a clean runner). + if (before) await inv('secure_session_set', { tokens: before }); + else await inv('secure_session_clear'); + const after = await inv('secure_session_get'); + return { supported, roundTrip: back?.accessToken === tokens.accessToken, restored: JSON.stringify(after) === JSON.stringify(before ?? null) }; + } catch (e) { + return { error: String(e).slice(0, 160) }; + } +}); +if (kc.error && /not found/i.test(kc.error)) record('keychain round trip', 'n/a', 'commands not in this build'); +else if (kc.error) record('keychain round trip', 'fail', kc.error); +else if (!kc.supported) record('keychain round trip', 'fail', 'secure_session_supported = false on Windows'); +else record('keychain round trip', kc.roundTrip && kc.restored ? 'pass' : 'fail', JSON.stringify(kc)); + +// 7. A foreign page loaded in the window must not get the microphone (#22). +await page.goto('https://example.com/').catch(() => undefined); +const foreignMic = await page.evaluate(async () => { + try { + const s = await navigator.mediaDevices.getUserMedia({ audio: true }); + s.getTracks().forEach((t) => t.stop()); + return 'ok'; + } catch (e) { + return e.name; + } +}); +record( + 'microphone refused to a foreign page', + foreignMic === 'NotAllowedError' ? 'pass' : 'info', + `${foreignMic}${foreignMic === 'ok' ? ' (build without the #22 origin check)' : ''}`, +); +await page.goto(APP).catch(() => undefined); +await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined); + +writeFileSync(`${OUT}/results.json`, JSON.stringify(results, null, 2)); +await browser.close().catch(() => undefined); +const failed = results.filter((r) => r.status === 'fail'); +console.log(`\n${results.filter((r) => r.status === 'pass').length} passed, ${failed.length} failed`); +process.exit(failed.length ? 1 : 0);