ci: Windows smoke test on the windows runner (#19)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-29 13:04:41 -04:00
co-authored by Claude Opus 5.5
parent ea69cd770e
commit 0d86f19935
2 changed files with 295 additions and 0 deletions
+120
View File
@@ -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
+175
View File
@@ -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 <outDir>
//
// 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);