Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
176 lines
7.1 KiB
JavaScript
176 lines
7.1 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.
|
|
//
|
|
// 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);
|