Files
cinny-desktop/scripts/windows-smoke.mjs
T
jaredandClaude Opus 5.5 13ebba77c0 test(windows-smoke): check the local server by connecting when sockets can't be listed
The `windows` runner session can't enumerate sockets: Get-NetTCPConnection
and netstat come back empty even for the DevTools port the script is
connected to, so "local server listening" failed on every run (main
too, run 3145) and masked real regressions.

When the socket list is empty, test what cinny #43 is about directly:
the server must accept a connection on 127.0.0.1:44548 and refuse it on
every non-loopback IPv4 address of the machine. The list-based check is
kept for runners where it works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-11-09 21:41:43 -05:00

495 lines
20 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 { connect } from 'node:net';
import { networkInterfaces } from 'node:os';
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));
}
};
// TCP connect probe: 'open', 'refused', 'timeout' or the socket error code.
const probe = (host, port, ms = 2000) =>
new Promise((resolve) => {
const socket = connect({ host, port });
const done = (result) => {
socket.destroy();
resolve(result);
};
socket.setTimeout(ms, () => done('timeout'));
socket.once('connect', () => done('open'));
socket.once('error', (e) => done(e.code === 'ECONNREFUSED' ? 'refused' : e.code || 'error'));
});
// 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).
let listen = '';
try {
listen = execSync(
'powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort 44548 -State Listen).LocalAddress -join \',\'"',
{ stdio: ['ignore', 'pipe', 'ignore'] },
)
.toString()
.trim();
} catch {
listen = '';
}
if (listen) {
record('local server listening', 'pass', listen);
} else {
// Some runner sessions can't list sockets at all (Get-NetTCPConnection and
// netstat both come back empty, even for the DevTools port this script is
// connected to). Check what #43 is about directly instead: the server
// answers on loopback and is not reachable on the machine's LAN addresses.
const loopback = await probe('127.0.0.1', 44548);
const lan = Object.values(networkInterfaces())
.flat()
.filter((a) => a && a.family === 'IPv4' && !a.internal)
.map((a) => a.address);
const exposed = [];
for (const ip of lan) {
if ((await probe(ip, 44548)) === 'open') exposed.push(ip);
}
record(
'local server listening',
loopback === 'open' && exposed.length === 0 ? 'pass' : 'fail',
`socket list unavailable; 127.0.0.1: ${loopback}; LAN ${lan.join(', ') || 'none'}: ${
exposed.length ? `OPEN on ${exposed.join(', ')}` : 'not reachable'
}`,
);
}
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}`,
);
// 11. Login tokens only in the keychain (cinny #105 step 2). Simulate an
// install from before step 2: a full session in localStorage, pointed at an
// unreachable server so nothing ever rejects (and logs out) the fake token.
// After a reload the tokens must have moved: localStorage keeps a token-less
// shell, Credential Manager holds them; after another reload the app is still
// signed in. Builds without step 2 only mirror (tokens stay): n/a.
{
const name = 'login tokens move to the keychain and stay signed in (#105)';
const KEY = 'cinny_session_v1';
const fake = {
accessToken: `syt_smoke_${Date.now()}`,
refreshToken: 'mar_smoke',
deviceId: 'SMOKEDEV',
userId: '@smoke:ci.invalid',
baseUrl: 'https://127.0.0.1:9',
};
const state = () =>
page.evaluate(async (k) => {
const blob = JSON.parse(localStorage.getItem(k) ?? 'null');
const kc = await window.__TAURI_INTERNALS__.invoke('secure_session_get').catch(() => null);
return {
tokensIn: blob?.tokensIn,
blobHasToken: typeof blob?.accessToken === 'string',
legacyToken: localStorage.getItem('cinny_access_token') !== null,
kcToken: kc?.accessToken ?? null,
loginScreen: /Username or email/.test(document.body.innerText),
};
}, KEY);
try {
await page.evaluate(([k, f]) => localStorage.setItem(k, JSON.stringify(f)), [KEY, fake]);
await page.reload();
// Step 2 mirrors the tokens, reads them back to verify, and only then strips
// them from localStorage, all in the same boot. Wait for the strip rather
// than stopping at the first "mirrored" sighting (that in-between state made
// a step-2 build look like a step-1 one).
const stripped = await until(async () => {
const st = await state();
return st.tokensIn === 'keychain' ? st : undefined;
}, 30_000);
const moved = stripped ?? (await state());
if (!stripped && moved.blobHasToken && moved.kcToken === fake.accessToken) {
record(name, 'n/a', 'build mirrors the tokens but keeps them in localStorage (before step 2)');
} else {
await page.reload();
await new Promise((r) => setTimeout(r, 8000));
const after = await state();
const ok =
moved &&
!moved.blobHasToken &&
!moved.legacyToken &&
moved.kcToken === fake.accessToken &&
after.tokensIn === 'keychain' &&
after.kcToken === fake.accessToken &&
!after.loginScreen;
record(name, ok ? 'pass' : 'fail', `after migration: ${JSON.stringify(moved)}; after restart: ${JSON.stringify(after)}`);
}
} catch (e) {
record(name, 'fail', String(e).slice(0, 200));
} finally {
// Leave the runner as we found it: no session, no keychain entry.
await page
.evaluate(async (k) => {
localStorage.removeItem(k);
await window.__TAURI_INTERNALS__.invoke('secure_session_clear').catch(() => undefined);
}, KEY)
.catch(() => undefined);
await page.reload().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);