Without Explorer the tray icon, taskbar overlay badge and thumbnail toolbar can't exist (E_FAIL / E_NOTIMPL). On a runner without an interactive desktop only those calls may fail, and the app must still respond. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
385 lines
16 KiB
JavaScript
385 lines
16 KiB
JavaScript
// 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 <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 { 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('<!doctype html><title>foreign</title>foreign 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);
|