// 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 // (including a `matrix:` deep link while hidden/minimised, #8). // // 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 { createServer } from 'node:http'; 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). // Served locally on another port (a different origin, still a secure context), // so the check doesn't depend on the runner reaching the internet. const foreign = createServer((_, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('foreignforeign page'); }); await new Promise((r) => foreign.listen(9333, '127.0.0.1', r)); const FOREIGN = 'http://localhost:9333/'; await page.goto(FOREIGN).catch(() => undefined); if (!page.url().startsWith(FOREIGN)) { record('microphone refused to a foreign page', 'fail', `navigation did not happen (at ${page.url()})`); } else { 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; } }); const guarded = cfg.desktopCallOrigin !== undefined; // builds with #22 also carry #43 let status = 'info'; if (foreignMic === 'NotAllowedError') status = 'pass'; else if (guarded) status = 'fail'; record( 'microphone refused to a foreign page', status, `${foreignMic} at ${page.url()}${status === 'info' ? ' (build without the #22 origin check)' : ''}`, ); } foreign.close(); await page.goto(APP).catch(() => undefined); await until(async () => /Login|Homeserver/.test(await page.locator('body').innerText()), 60_000); // 8. A `matrix:` link opened from Windows while the window is hidden to the // tray, then while minimised, must bring the window back AND reach the page // as `lotus-deeplink` (#8). It goes the real route: Windows' protocol handler // starts a second cinny.exe, and single-instance forwards the URL to this one. // (Turning the URL into a room navigation is the web app's job, signed in.) const win = (cmd) => page.evaluate( ([c]) => window.__TAURI_INTERNALS__.invoke(`plugin:window|${c}`, { label: 'main' }), [cmd], ); await page.evaluate(() => { window.__deeplinks = []; window.addEventListener('lotus-deeplink', (e) => window.__deeplinks.push(e.detail)); }); // Can this machine show a hidden window at all? A runner without an // interactive desktop (e.g. act_runner as a Windows service, session 0) can't, // and can't inject keys either. Then visibility and hotkeys are reported n/a // instead of failing for a reason that isn't the app's. let interactive = false; try { await win('hide'); await new Promise((r) => setTimeout(r, 1000)); await win('show'); await new Promise((r) => setTimeout(r, 1000)); interactive = await win('is_visible'); } catch { interactive = false; } record( 'interactive desktop (windows can be shown, keys injected)', interactive ? 'pass' : 'info', interactive ? 'yes' : 'no: visibility and hotkey checks are n/a on this runner', ); for (const [state, put] of [ ['hidden to the tray', 'hide'], ['minimised', 'minimize'], ]) { const name = `deep link while ${state} reveals the window and reaches the page`; const url = `matrix:r/lotus-smoke-${put}:example.org`; try { await win(put); await new Promise((r) => setTimeout(r, 1500)); const away = put === 'hide' ? !(await win('is_visible')) : await win('is_minimized'); execSync(`cmd /c start "" "${url}"`); const got = await until( () => page.evaluate((u) => window.__deeplinks.includes(u), url), 20_000, ); const visible = await win('is_visible'); const minimised = await win('is_minimized'); const delivered = away && got && !minimised; const ok = delivered && (visible || !interactive); record( name, ok ? 'pass' : 'fail', `${state} first: ${away}; event: ${!!got}; minimised after: ${minimised}; visible after: ${visible}${ interactive ? '' : ' (not checked: no interactive desktop)' }`, ); } catch (e) { record(name, 'fail', String(e).slice(0, 200)); } } // 9. System-wide voice hotkeys (#2): register PTT on F9, press and release F9 // through Windows (keybd_event, as a game in front would see it), expect a // pressed and a released `lotus-global-hotkey`; after clearing the bindings // the same key must produce nothing (the poll stopped). const pressScript = `${OUT}/press-f9.ps1`; writeFileSync( pressScript, [ 'Add-Type -Name K -Namespace W -MemberDefinition @"', '[DllImport("user32.dll")] public static extern void keybd_event(byte v, byte s, uint f, System.UIntPtr e);', '"@', '[W.K]::keybd_event(0x78, 0, 0, [System.UIntPtr]::Zero)', 'Start-Sleep -Milliseconds 300', '[W.K]::keybd_event(0x78, 0, 2, [System.UIntPtr]::Zero)', ].join('\r\n'), ); const pressF9 = () => execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${pressScript}"`); try { const supported = await page.evaluate(() => window.__TAURI_INTERNALS__.invoke('global_hotkeys_supported'), ); if (!supported) { record('global hotkeys reach the page', 'fail', 'global_hotkeys_supported = false on Windows'); } else if (!interactive) { record('global hotkeys reach the page', 'n/a', 'no interactive desktop: injected keys never reach GetAsyncKeyState'); } else { await page.evaluate(async () => { window.__hotkeys = []; window.addEventListener('lotus-global-hotkey', (e) => window.__hotkeys.push(e.detail)); await window.__TAURI_INTERNALS__.invoke('set_global_hotkeys', { bindings: [{ id: 'ptt', code: 'F9' }], }); }); await new Promise((r) => setTimeout(r, 500)); pressF9(); const seen = await until( () => page.evaluate(() => { const h = window.__hotkeys.map((e) => `${e.id}:${e.state}`); return h.includes('ptt:pressed') && h.includes('ptt:released') ? h : undefined; }), 5_000, 250, ); await page.evaluate(() => window.__TAURI_INTERNALS__.invoke('set_global_hotkeys', { bindings: [] }), ); const before = await page.evaluate(() => window.__hotkeys.length); pressF9(); await new Promise((r) => setTimeout(r, 1500)); const after = await page.evaluate(() => window.__hotkeys.length); record( 'global hotkeys reach the page, and stop when cleared', seen && after === before ? 'pass' : 'fail', `events: ${JSON.stringify(seen ?? (await page.evaluate(() => window.__hotkeys)))}; after clearing: ${after - before} more`, ); } } catch (e) { const msg = String(e).slice(0, 200); record('global hotkeys reach the page', /not found/i.test(msg) ? 'n/a' : 'fail', msg); } // 10. Tray / badge / taskbar / thumbbar / media-controls commands (#4, #7): // the state they set is only visible to a person on a desktop, but each must // accept what cinny sends and leave the app working. Then reset everything. const calls = [ ['set_badge_count', { count: 3 }], ['set_tray_unread', { unread: true }], ['set_call_active', { active: true }], ['set_tray_call_state', { active: true, muted: true, deafened: false }], ['set_thumbbar', { active: true, muted: true, deafened: false }], ['set_thumbbar', { active: true, muted: false, deafened: true }], ['set_smtc_call_state', { active: true, muted: true }], ['set_taskbar_progress', { status: 'normal', progress: 40 }], ['set_taskbar_progress', { status: 'indeterminate', progress: null }], ['set_jump_list', { items: [{ title: 'Smoke room', uri: 'matrix:r/lotus-smoke:example.org' }] }], ['set_tray_update_ready', { version: '9.9.9' }], ['flash_window', {}], ['get_tray_dnd', {}], ['has_tray_host', {}], // reset ['set_badge_count', { count: 0 }], ['set_tray_unread', { unread: false }], ['set_tray_call_state', { active: false, muted: false, deafened: false }], ['set_thumbbar', { active: false, muted: false, deafened: false }], ['set_smtc_call_state', { active: false, muted: false }], ['set_call_active', { active: false }], ['set_taskbar_progress', { status: 'none', progress: null }], ['set_jump_list', { items: [] }], ['set_tray_update_ready', { version: null }], ]; const failures = []; for (const [cmd, args] of calls) { // eslint-disable-next-line no-await-in-loop const err = await page .evaluate( async ([c, a]) => { try { await window.__TAURI_INTERNALS__.invoke(c, a); return null; } catch (e) { return String(e).slice(0, 120); } }, [cmd, args], ) .catch((e) => `page gone: ${String(e).slice(0, 80)}`); if (err) failures.push(`${cmd}(${JSON.stringify(args)}): ${err}`); } const alive = await page.evaluate(() => document.title !== undefined).catch(() => false); // Without a desktop there is no Explorer shell: the tray icon, the taskbar // overlay badge and the thumbnail toolbar can't exist, and Windows answers // E_FAIL / E_NOTIMPL. cinny ignores those errors; the app must just survive. const SHELL_ONLY = /^(set_badge_count|set_tray_unread|set_tray_call_state|set_tray_update_ready|set_thumbbar)\(/; const real = interactive ? failures : failures.filter((f) => !SHELL_ONLY.test(f)); record( 'tray, badge, taskbar, thumbbar and media-control commands', real.length === 0 && alive ? 'pass' : 'fail', real.length ? real.join(' | ') : `${calls.length - failures.length}/${calls.length} calls ok${ failures.length ? `, ${failures.length} shell-only calls n/a without a desktop` : '' }; app still responsive: ${alive}`, ); 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);