/** * @fileoverview The Codeman Desktop connect page (packages/desktop/assets/connect.html), driven in * a real browser through every state of both tabs. * * The page is the app's whole interface before Codeman's own UI loads. It asks the native side for * things with `window.ipc.postMessage(JSON)` and hears back through * `window.__codemanDesktop.onNative(msg)`: here a stub records the first and the test plays the * second. The page is served with the Content-Security-Policy the app serves it with, read out of * packages/desktop/src/assets.rs, so a script that policy would block fails here as it would in the * app. * * Chromium stands in for the app's WKWebView, so what this pins is the page's own logic and layout, * not WebKit's rendering. The Rust side of the same messages is pinned by the unit tests in * packages/desktop/src (policy.rs), which do not build on Linux CI. * * Each test is one long, ordered walk through the page (a later step builds on the state the * earlier ones left), so a walk collects every check that fails and reports them together. * * Browser-driven, so excluded from `npm test` (config/test-suites.ts). Run locally: * npm run test:browser -- test/desktop-connect-page.browser.test.ts * * Port: N/A (the page is served by request interception; nothing listens). */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; const PKG = resolve(import.meta.dirname, '../packages/desktop'); const html = readFileSync(resolve(PKG, 'assets/connect.html'), 'utf8'); const icon = readFileSync(resolve(PKG, 'assets/icon.png')); const assetsRs = readFileSync(resolve(PKG, 'src/assets.rs'), 'utf8'); // The connect page's real CSP (assets::CSP), so a check that needs eval or an outside resource // fails here as it would in the app. Only img-src also admits the test origin, since the icon // comes from http://app.test here, not codemanapp:. const CSP = /const CSP: &str = "([^"]*)";/.exec(assetsRs)![1]; const CSP_HERE = CSP.replace('img-src codemanapp:', 'img-src codemanapp: http://app.test'); const WEBKIT_NOTICE = 'Codeman needs a newer WebKit than this Mac has: macOS 13.3 or later, or Safari 16.4 or later on macOS 11 and 12.'; // The native side as the page sees it: `ipc.postMessage` records what the page asks for. const IPC_STUB = `window.__posted = []; Object.defineProperty(window, 'ipc', { value: Object.freeze({ postMessage: function (s) { window.__posted.push(JSON.parse(s)); } }) });`; /* eslint-disable @typescript-eslint/no-explicit-any */ declare global { interface Window { __posted: any[]; __codemanDesktop: { onNative(msg: unknown): void }; __codemanStaticBlocks?: boolean; ipc: { postMessage(message: string): void }; } } /** The checks of the walk under way that did not hold, as `name -> what was seen`. */ const failed: string[] = []; function check(name: string, cond: unknown, extra?: unknown) { if (!cond) failed.push(extra === undefined ? name : `${name} -> ${JSON.stringify(extra)}`); } // Serves the connect page under its real CSP; `body` replaces the page for one test. async function serveConnect(page: Page, body?: string) { await page.route('http://app.test/**', (route) => { const p = new URL(route.request().url()).pathname; if (p === '/icon.png') return route.fulfill({ body: icon, contentType: 'image/png' }); return route.fulfill({ body: body || html, contentType: 'text/html', headers: { 'Content-Security-Policy': CSP_HERE }, }); }); } async function everyState(browser: Browser) { const ctx = await browser.newContext({ viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const errors = []; const cspViolations = []; page.on('pageerror', (e) => errors.push(e.message)); page.on('console', (m) => { if (/Content Security Policy/i.test(m.text())) cspViolations.push(m.text()); }); await serveConnect(page); await page.addInitScript(IPC_STUB); const posted = () => page.evaluate(() => window.__posted.filter((m) => m.cmd !== 'localStatus')); const native = (msg) => page.evaluate((m) => window.__codemanDesktop.onNative(m), msg); const visible = (sel) => page.locator(sel).isVisible(); const remoteTab = () => page.click('#tab-remote'); // 1. First run: no servers. await page.goto('http://app.test/index.html'); check('connect page posts ready on load', JSON.stringify(await posted()) === '[{"cmd":"ready"}]', await posted()); await native({ type: 'init', servers: [], autoConnect: true, startFullscreen: false, version: '0.1.0' }); check( 'first run opens the This Mac tab and asks about this Mac', (await visible('#pane-local')) && !(await visible('#form')) && (await page.evaluate(() => window.__posted.some((m) => m.cmd === 'localStatus' && m.quick === false))) ); check('first run does not auto-connect', (await posted()).length === 1); check('version shown in footer', (await page.textContent('#version')).includes('0.1.0')); // The static-block probe passes under the real CSP (no eval needed). check( 'page is served with the real CSP (no unsafe-eval)', /script-src 'unsafe-inline';/.test(CSP) && !CSP.includes('unsafe-eval'), CSP ); // In a later task: code running inside page.evaluate may use eval whatever the // CSP says (CDP's allowUnsafeEvalBlockedByCSP), a timer callback may not. const evalResult = await page.evaluate( () => new Promise((resolve) => setTimeout(() => { try { new Function('return 1'); resolve('allowed'); } catch (e) { resolve(e.name); } }, 0) ) ); check('the real CSP blocks new Function, so the probe cannot use it', evalResult === 'EvalError', evalResult); check( 'a WebKit with static blocks: no notice, Connect enabled', !(await visible('#webkit')) && !(await page.isDisabled('#connect')) && (await page.evaluate(() => window.__codemanStaticBlocks)) === true ); check( 'the icon loads under the CSP', await page.evaluate(() => { const i = document.querySelector('header img'); return i.complete && i.naturalWidth > 0; }) ); // 2. Returning user with auto-connect. await page.goto('http://app.test/index.html'); await native({ type: 'init', servers: [ { url: 'http://127.0.0.1:3000/' }, { url: 'https://box.tail.ts.net/', username: 'admin', hasSavedPassword: true }, ], autoConnect: true, startFullscreen: true, version: '0.1.0', }); let p = await posted(); check( 'auto-connect posts connect for the last server', p[1] && p[1].cmd === 'connect' && p[1].url === 'http://127.0.0.1:3000/' && !('password' in p[1]), p ); check( 'busy state: connect disabled, cancel shown', (await page.isDisabled('#connect')) && (await visible('#cancel')) ); check('recent chips rendered', (await page.locator('.chip').count()) === 2); check('full screen checkbox reflects init', await page.isChecked('#fullscreen')); // 3. Manual mode (Switch Server) never auto-connects. await page.goto('http://app.test/index.html?manual=1'); await native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, version: '0.1.0' }); check('manual=1 suppresses auto-connect', (await posted()).length === 1, await posted()); // 4. Needs login -> login fields, then a typed password goes out. await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'needsLogin' }, canStart: false, }); check('needsLogin reveals the login fields', await visible('#login')); check('username defaults to admin', (await page.inputValue('#username')) === 'admin'); check('needsLogin message shown', (await page.textContent('#status')).includes('asks for a login')); await page.fill('#password', 's3cret'); await page.click('#connect'); p = await posted(); const last = p[p.length - 1]; check( 'submit sends username + password + remember', last.cmd === 'connect' && last.username === 'admin' && last.password === 's3cret' && last.remember === true, last ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'badCredentials' }, canStart: false, }); check('badCredentials message shown', (await page.textContent('#status')).includes('Wrong username or password')); // 5. Nothing listening on loopback -> Start button -> startServer -> reconnect. await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'refused' }, canStart: true }); check('refused + canStart shows the Start button', await visible('#start')); check('refused + canStart explains', (await page.textContent('#status')).includes('not running on this Mac')); await page.click('#start'); p = await posted(); check( 'Start posts startServer with the URL', p[p.length - 1].cmd === 'startServer' && p[p.length - 1].url === 'http://127.0.0.1:3000/', p[p.length - 1] ); await native({ type: 'serverStarting' }); check('starting hides Start and shows the spinner', !(await visible('#start')) && (await visible('#spinner'))); const before = (await posted()).length; await native({ type: 'serverStartResult', ok: true, output: 'Codeman started (pid 123)', url: 'http://127.0.0.1:3000/', }); p = await posted(); check( 'server started -> connects again', p.length === before + 1 && p[p.length - 1].cmd === 'connect', p.slice(before) ); await native({ type: 'probeResult', url: 'http://10.0.0.2:3000/', outcome: { kind: 'refused' }, canStart: false }); check('refused without canStart hides the Start button', !(await visible('#start'))); // 6. Other outcomes are explained, and server text is never HTML. await native({ type: 'probeResult', url: 'https://127.0.0.1:3000/', outcome: { kind: 'tlsUntrusted', detail: 'UnknownIssuer' }, canStart: false, }); const tls = await page.textContent('#status'); check( 'tlsUntrusted explains the self-signed case', tls.includes('not trusted') && tls.includes('self-signed') && tls.includes('UnknownIssuer') ); // findings2 contested [con1]: Keychain trust can only help at the names the certificate carries. check( 'tlsUntrusted at 127.0.0.1: Keychain advice, hedged, and http://127.0.0.1', tls.includes('Keychain Access may also work') && tls.includes('http://127.0.0.1:PORT') && !tls.includes('tailscale serve'), tls ); await native({ type: 'probeResult', url: 'https://localhost:3000/', outcome: { kind: 'tlsUntrusted' }, canStart: false, }); let tls2 = await page.textContent('#status'); check('tlsUntrusted at localhost: Keychain advice too', tls2.includes('Keychain Access may also work'), tls2); for (const u of [ 'https://192.168.1.20:3000/', 'https://studio.local:3000/', 'https://box.tail.ts.net/', 'https://[::1]:3000/', 'https://127.0.0.1.nip.io:3000/', ]) { await native({ type: 'probeResult', url: u, outcome: { kind: 'tlsUntrusted', detail: 'NotValidForName' }, canStart: false, }); tls2 = await page.textContent('#status'); check( `tlsUntrusted at ${u}: no Keychain advice; tailscale serve, a reverse proxy, http://127.0.0.1 for this Mac`, !tls2.includes('Keychain') && tls2.includes('tailscale serve') && tls2.includes('reverse proxy with a real certificate') && tls2.includes('http://127.0.0.1:PORT') && tls2.includes('names only localhost and 127.0.0.1') && tls2.includes('NotValidForName'), tls2 ); } await native({ type: 'probeResult', url: 'http://h/', outcome: { kind: 'notCodeman', status: 404 }, canStart: false, }); check('notCodeman shows the status', (await page.textContent('#status')).includes('HTTP 404')); await native({ type: 'serverStartResult', ok: false, output: '' }); check( 'server output is rendered as text, not HTML', (await page.evaluate(() => window.__xss)) === undefined && (await page.textContent('#status')).includes('> nth=1'); p = (await posted()).slice(n0); check( 'chip double-click posts one connect', p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://10.0.0.5:3000/', p ); check( 'chips are disabled while busy', await page.evaluate(() => Array.from(document.querySelectorAll('#recent button')).every((b) => b.disabled)) ); n0 = (await posted()).length; await page.evaluate(() => { const b = document.querySelector('.chip button'); b.disabled = false; b.click(); document.getElementById('form').requestSubmit(); }); check( 'chip click and submit are refused while busy, even when re-enabled', (await posted()).length === n0, (await posted()).slice(n0) ); await native({ type: 'servers', servers: [{ url: 'http://127.0.0.1:3000/' }] }); check('a server list that arrives mid-probe stays disabled', await page.isDisabled('.chip button')); await native({ type: 'probeResult', url: 'http://10.0.0.5:3000/', outcome: { kind: 'refused' }, canStart: false }); check('chips enabled again after the result', !(await page.isDisabled('.chip button'))); // 9. Starting Codeman: no Cancel, and the result connects only to the server that was asked for. await page.fill('#url', '127.0.0.1:3000'); await page.click('#connect'); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'refused' }, canStart: true }); await page.click('#start'); p = await posted(); check( 'Start asks for the typed server', p[p.length - 1].cmd === 'startServer' && p[p.length - 1].url === '127.0.0.1:3000', p[p.length - 1] ); await native({ type: 'serverStarting' }); check( 'serverStarting: Cancel hidden, Connect disabled, chips disabled', !(await visible('#cancel')) && (await page.isDisabled('#connect')) && (await page.isDisabled('.chip button')) ); n0 = (await posted()).length; await native({ type: 'serverStartResult', ok: true, output: 'started', url: 'http://127.0.0.1:4000/' }); check( 'serverStartResult for another URL does not connect', (await posted()).length === n0 && (await page.textContent('#status')).includes('Codeman started'), (await posted()).slice(n0) ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'refused' }, canStart: true }); await page.click('#start'); await native({ type: 'serverStarting' }); await page.fill('#url', 'http://10.0.0.9:3000'); n0 = (await posted()).length; await native({ type: 'serverStartResult', ok: true, output: 'started', url: 'http://127.0.0.1:3000/' }); p = (await posted()).slice(n0); check( 'a matching result (scheme and slash normalized) connects to the started server, not the edited field', p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://127.0.0.1:3000/', p ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'refused' }, canStart: true }); await page.click('#start'); await native({ type: 'serverStarting' }); n0 = (await posted()).length; await native({ type: 'serverStartResult', ok: false, output: 'boom', url: 'http://127.0.0.1:3000/' }); check( 'a failed start does not connect', (await posted()).length === n0 && (await page.textContent('#status')).includes('did not start') ); // 10. A page reloaded while a start runs shows it running and does not auto-connect. await page.goto('http://app.test/index.html'); await native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, startFullscreen: false, version: '0.1.0', starting: true, }); check('init.starting does not auto-connect', (await posted()).length === 1, await posted()); check( 'init.starting shows the start running without Cancel', (await visible('#spinner')) && !(await visible('#cancel')) && (await page.isDisabled('#connect')) && (await page.textContent('#status')).includes('Starting Codeman') ); await native({ type: 'serverStartResult', ok: true, output: '', url: 'http://127.0.0.1:3000/' }); p = await posted(); check( 'after a reload, the result connects to the server the field shows', p.length === 2 && p[1].cmd === 'connect' && p[1].url === 'http://127.0.0.1:3000/', p ); await page.goto('http://app.test/index.html'); await native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, startFullscreen: false, version: '0.1.0', notice: 'Codeman did not open. Check that the server is still running.', }); check( 'init.notice shown and no auto-connect', (await page.textContent('#status')).includes('did not open') && (await posted()).length === 1 ); // 11. Menu changes reach the page. await native({ type: 'prefs', startFullscreen: true, autoConnect: true }); check('prefs updates both checkboxes', (await page.isChecked('#fullscreen')) && (await page.isChecked('#auto'))); await native({ type: 'prefs', startFullscreen: false }); check( 'prefs updates only what it carries', !(await page.isChecked('#fullscreen')) && (await page.isChecked('#auto')) ); // 12. Rate limited: the login fields come back. await page.goto('http://app.test/index.html?manual=1'); await native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: false, version: '0.1.0' }); check('no saved username: login hidden', !(await visible('#login'))); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'rateLimited', sentCredentials: false }, canStart: false, }); let st = await page.textContent('#status'); check( 'rateLimited reveals the login with admin and focuses the password', (await visible('#login')) && (await page.inputValue('#username')) === 'admin' && (await page.evaluate(() => document.activeElement.id)) === 'password' ); check( 'rateLimited text: enter the password or wait, no blame without credentials', st.includes('Too many failed logins') && st.includes('single-user') && st.includes('15 minutes') && !st.includes('may be wrong'), st ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'rateLimited', sentCredentials: true }, canStart: false, }); check( 'rateLimited with credentials says the password may be wrong', (await page.textContent('#status')).includes('may be wrong') ); // 13. Redirects, with and without a suggested address. await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'redirect', location: 'https://127.0.0.1:3000/api/status', suggested: 'https://127.0.0.1:3000/' }, canStart: false, }); st = await page.textContent('#status'); check('redirect shows the location', st.includes('https://127.0.0.1:3000/api/status'), st); check( 'redirect shows a Use button', (await visible('#suggest')) && (await page.textContent('#suggest')) === 'Use https://127.0.0.1:3000/' ); n0 = (await posted()).length; await page.dblclick('#suggest'); p = (await posted()).slice(n0); check( 'Use fills the field and connects once', p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'https://127.0.0.1:3000/' && (await page.inputValue('#url')) === 'https://127.0.0.1:3000/', p ); check( 'suggest button disabled (not hidden) while busy', (await visible('#suggest')) && (await page.isDisabled('#suggest')) ); await native({ type: 'probeResult', url: 'http://h/', outcome: { kind: 'redirect', location: 'https://login.example/x', suggested: null }, canStart: false, }); st = await page.textContent('#status'); check( 'redirect without suggestion: location as text, no button', st.includes('https://login.example/x') && !(await visible('#suggest')), st ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'redirect', location: 'https://127.0.0.1:3000/api/status', suggested: 'https://127.0.0.1:3000/' }, canStart: false, }); await page.fill('#url', 'http://other:3000'); check('editing the address hides the suggestion', !(await visible('#suggest'))); // 14. Network outcomes and their hints. await native({ type: 'probeResult', url: 'http://192.168.1.20:3000/', outcome: { kind: 'unreachable' }, canStart: false, }); st = await page.textContent('#status'); check( 'unreachable: Local Network hint and same network', st.includes('Privacy & Security > Local Network') && st.includes('same network') && st.includes('may'), st ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'maybeHttps' }, canStart: false, }); st = await page.textContent('#status'); check( 'maybeHttps: --https, https:// and the self-signed limit', st.includes('--https') && st.includes('https://') && st.includes('self-signed'), st ); check( 'maybeHttps offers the https:// address', (await visible('#suggest')) && (await page.textContent('#suggest')) === 'Use https://127.0.0.1:3000/' ); check( 'maybeHttps at 127.0.0.1: Keychain advice, hedged', st.includes('Keychain Access may work') && !st.includes('tailscale serve'), st ); await native({ type: 'probeResult', url: 'http://10.0.0.2:3000/', outcome: { kind: 'maybeHttps' }, canStart: false }); st = await page.textContent('#status'); check( 'maybeHttps at another host: no Keychain advice; tailscale serve or a reverse proxy', !st.includes('Keychain') && st.includes('tailscale serve') && st.includes('reverse proxy with a real certificate') && st.includes('--https') && st.includes('self-signed'), st ); check( 'maybeHttps at another host still offers the https:// address', (await visible('#suggest')) && (await page.textContent('#suggest')) === 'Use https://10.0.0.2:3000/' ); await native({ type: 'probeResult', url: 'http://studio.local:3000/', outcome: { kind: 'unresolvable' }, canStart: false, }); st = await page.textContent('#status'); check('unresolvable .local: Local Network hint', st.includes('Local Network') && !(await visible('#suggest')), st); await native({ type: 'probeResult', url: 'https://box.tail.ts.net/', outcome: { kind: 'unresolvable' }, canStart: false, }); st = await page.textContent('#status'); check( 'unresolvable .ts.net: Tailscale hint, no Local Network', st.includes('Tailscale') && !st.includes('Local Network'), st ); // 15. Opening, including a forced password change. await page.click('#connect'); await native({ type: 'opening', version: null, passwordChange: true }); st = await page.textContent('#status'); check( 'opening with passwordChange explains the new password', st.includes('new password') && st.includes('next time'), st ); check('opening keeps Cancel (the load can be abandoned)', await visible('#cancel')); await native({ type: 'opening', version: null }); check( 'opening without a version', (await page.textContent('#status')) === 'Opening Codeman…', await page.textContent('#status') ); await native({ type: 'opening', version: '1.47.0' }); check('opening with a version', (await page.textContent('#status')) === 'Opening Codeman 1.47.0…'); n0 = (await posted()).length; await page.click('#cancel'); p = (await posted()).slice(n0); check('Cancel while opening posts cancel', p.length === 1 && p[0].cmd === 'cancel', p); // 16. This Mac: the four states, Open / Start / Install, and the install wait. const all = () => page.evaluate(() => window.__posted.slice()); const st16 = (state, extra) => Object.assign( { url: 'http://127.0.0.1:3000/', state, version: null, service: false, installed: state !== 'notInstalled', installComplete: false, outcome: { kind: state === 'running' ? 'ok' : 'refused' }, }, extra || {} ); await page.goto('http://app.test/index.html'); await native({ type: 'init', servers: [{ url: 'https://box.tail.ts.net/', username: 'admin', hasSavedPassword: true }], autoConnect: false, startFullscreen: false, version: '0.1.0', }); check( 'a remote server used last opens the Another machine tab, without a This Mac check', (await visible('#form')) && !(await visible('#pane-local')) && !(await all()).some((m) => m.cmd === 'localStatus') ); check( 'tabs: aria-selected follows the tab', (await page.getAttribute('#tab-remote', 'aria-selected')) === 'true' && (await page.getAttribute('#tab-local', 'aria-selected')) === 'false' ); await page.click('#tab-local'); check( 'switching to This Mac asks about this Mac once', (await all()).filter((m) => m.cmd === 'localStatus').length === 1 && (await page.textContent('#local-title')).includes('Looking for Codeman') ); await page.click('#tab-remote'); await page.click('#tab-local'); check( 'switching back does not ask again while a check runs', (await all()).filter((m) => m.cmd === 'localStatus').length === 1 ); await page.focus('#tab-local'); await page.keyboard.press('ArrowRight'); check( 'arrow keys move between the tabs', (await visible('#form')) && (await page.evaluate(() => document.activeElement.id)) === 'tab-remote' ); await page.keyboard.press('ArrowLeft'); await native({ type: 'localStatus', status: st16('running', { version: '1.47.0', outcome: { kind: 'ok', version: '1.47.0' } }), }); check( 'running: Open shown, title and version', (await visible('#local-open')) && !(await visible('#local-install')) && (await page.textContent('#local-title')).includes('running') && (await page.textContent('#local-detail')).includes('1.47.0') ); n0 = (await all()).length; await page.dblclick('#local-open'); p = (await all()).slice(n0); check( 'Open connects once to the local URL, with no password from the page', p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://127.0.0.1:3000/' && !('password' in p[0]), p ); check( "Open never carries the remote server's username shown before (review #1)", !('username' in p[0]) && !(await page.isVisible('#login')), p ); check( 'Open: busy, Open disabled, Cancel shown', (await page.isDisabled('#local-open')) && (await visible('#cancel')) ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'needsLogin' }, canStart: true }); check( 'a login asked for from This Mac moves to the login fields', (await visible('#form')) && (await visible('#login')) ); await page.click('#tab-local'); // A typed password for another server must not ride along to Start either. await page.click('#tab-remote'); await page.fill('#url', 'https://other.example/'); await native({ type: 'probeResult', url: 'https://other.example/', outcome: { kind: 'needsLogin' }, canStart: false, }); await page.fill('#password', 'remote-secret'); await page.click('#tab-local'); await native({ type: 'localStatus', status: st16('stopped', { service: true }) }); check( 'stopped: Start and Check again, service named', (await visible('#local-start')) && (await visible('#local-check')) && !(await visible('#local-open')) && (await page.textContent('#local-detail')).includes('start when you log in') ); n0 = (await all()).length; await page.click('#local-start'); p = (await all()).slice(n0); check( 'Start posts startServer for the local URL', p.length === 1 && p[0].cmd === 'startServer' && p[0].url === 'http://127.0.0.1:3000/', p ); check( "Start clears the other server's login from the form", (await page.inputValue('#password')) === '' && (await page.inputValue('#username')) === '' ); await native({ type: 'serverStarting' }); check('starting: Start disabled', await page.isDisabled('#local-start')); n0 = (await all()).length; await native({ type: 'serverStartResult', ok: true, output: 'started', url: 'http://127.0.0.1:3000/' }); p = (await all()).slice(n0); check( 'started: connects to the local URL', p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://127.0.0.1:3000/', p ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'refused' }, canStart: true }); check('a refused Open from This Mac looks at this Mac again', (await all()).slice(-1)[0].cmd === 'localStatus'); await native({ type: 'localStatus', status: st16('notInstalled') }); check( 'not installed: Install, no Open/Start', (await visible('#local-install')) && !(await visible('#local-open')) && !(await visible('#local-start')) ); // Install opens the graphical install (test/desktop-connect-install.test.ts walks its screens). // The Terminal route is what it offers when it cannot go on, here after a failed preparation. const installInTerminal = async () => { await page.click('#local-install'); await native({ type: 'installPrepareFailed', message: 'Could not get the installer.' }); await page.click('#wiz-terminal'); }; n0 = (await all()).length; await page.dblclick('#local-install'); p = (await all()).slice(n0); check('a double click on Install asks for the install once', p.length === 1 && p[0].cmd === 'installPrepare', p); await native({ type: 'installPrepareFailed', message: 'Could not get the installer.' }); n0 = (await all()).length; await page.dblclick('#wiz-terminal'); p = (await all()).slice(n0); check( 'Install in Terminal instead posts installLocal once', p.length === 1 && p[0].cmd === 'installLocal' && p[0].network === false, p ); check('Install disabled while Terminal opens', await page.isDisabled('#local-install')); await native({ type: 'installFailed', message: 'Could not open Terminal: no' }); check( 'install failure shown as text, Install enabled again', (await page.textContent('#status')).includes('no') && !(await page.isDisabled('#local-install')) ); await installInTerminal(); await page.clock.install(); await native({ type: 'installStarted', port: 3001 }); check( 'install started: waiting state with Stop waiting', (await visible('#local-stop')) && !(await visible('#local-install')) && (await page.textContent('#local-title')).includes('Installing') ); n0 = (await all()).length; await page.clock.runFor(3100); p = (await all()).slice(n0); check('the wait polls with quick checks', p.length === 1 && p[0].cmd === 'localStatus' && p[0].quick === true, p); // Port 3000 is a dev server; the install took 3001. await native({ type: 'localStatus', status: st16('other', { installed: false, outcome: { kind: 'notCodeman', status: 404 } }), }); check('something else on 3000 does not end a wait for 3001', await visible('#local-stop')); await page.clock.runFor(3100); n0 = (await all()).length; await native({ type: 'localStatus', status: st16('running', { url: 'http://127.0.0.1:3001/', version: '1.47.0', service: true, installComplete: true, outcome: { kind: 'ok', version: '1.47.0' }, }), }); p = (await all()).slice(n0); check( 'the installed server answering on its port opens Codeman', p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://127.0.0.1:3001/' && !(await visible('#local-stop')), p ); await page.clock.runFor(10000); check('no more polling after the wait ends', !(await all()).slice(n0 + 1).some((m) => m.cmd === 'localStatus')); await native({ type: 'probeResult', url: 'http://127.0.0.1:3001/', outcome: { kind: 'timeout' }, canStart: true }); // A finished install whose service never answers offers Start after 90 s. await native({ type: 'localStatus', status: st16('notInstalled') }); await installInTerminal(); await native({ type: 'installStarted', port: 3000 }); await native({ type: 'localStatus', status: st16('stopped', { installComplete: true }) }); await page.clock.runFor(60000); await native({ type: 'localStatus', status: st16('stopped', { installComplete: true }) }); check('a finished install keeps waiting for its service for a while', await visible('#local-stop')); await page.clock.runFor(40000); await native({ type: 'localStatus', status: st16('stopped', { installComplete: true }) }); check( 'then stops waiting and offers Start', !(await visible('#local-stop')) && (await visible('#local-start')) && (await page.textContent('#status')).includes('not answering') ); // Stop waiting by hand. await native({ type: 'localStatus', status: st16('notInstalled') }); await installInTerminal(); await native({ type: 'installStarted', port: 3000 }); n0 = (await all()).length; await page.click('#local-stop'); p = (await all()).slice(n0); check( 'Stop waiting ends the wait and looks again (full check)', !(await visible('#local-stop')) && p.length === 1 && p[0].cmd === 'localStatus' && p[0].quick === false, p ); await page.clock.runFor(10000); check( 'no polling after Stop waiting', (await all()).slice(n0 + 1).every((m) => m.cmd !== 'localStatus' || m.quick === false) ); // Review fixes: Check again looks deeper; a server elsewhere; an install still running; https and daemon. await native({ type: 'localStatus', status: st16('notInstalled') }); n0 = (await all()).length; await page.click('#local-check'); p = (await all()).slice(n0); check( 'Check again asks for the deep look (interactive profile)', p.length === 1 && p[0].cmd === 'localStatus' && p[0].deep === true && p[0].quick === false, p ); const elsewhere = [ { url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false }, { url: 'http://192.168.1.5:3000/', version: '1.49.0', state: 'running', isDefault: false }, ]; await native({ type: 'localStatus', status: st16('runningElsewhere', { found: elsewhere }) }); check( 'a server running elsewhere: no Start, no Install, a row for each address', !(await visible('#local-start')) && !(await visible('#local-install')) && !(await visible('#local-open')) && (await page.textContent('#local-found')).includes('http://192.168.1.5:3000/') && (await page.locator('#local-found .found').count()) === 2 && (await page.textContent('#local-title')).includes('already running') ); const foundBox = await page.evaluate(() => { const r = (sel) => document.querySelector(sel).getBoundingClientRect(); const card = r('.card'); const rows = [...document.querySelectorAll('#local-found .found')].map((row) => row.getBoundingClientRect()); return { order: [r('.local').bottom, rows[0].top, rows[1].bottom, r('#local-found-note').top, r('#local-check').top], inside: rows.every((row) => row.left >= card.left && row.right <= card.right), buttons: [...document.querySelectorAll('#local-found .found')].map((row) => { const b = row.querySelector('button'); return b ? b.className : ''; }), }; }); check( 'the rows sit under the title, then the sentence about a second server, then Check again', foundBox.order.every((y, i) => i === 0 || y >= foundBox.order[i - 1]) && foundBox.inside, foundBox ); check( 'a server the app cannot open has no Connect; the first it can open is the main action', foundBox.buttons[0] === '' && foundBox.buttons[1] === 'primary', foundBox.buttons ); n0 = (await all()).length; await page.locator('#local-found .found button').dblclick(); p = (await all()).slice(n0); check( 'Connect on a found server connects once, to its address', p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://192.168.1.5:3000/' && !('password' in p[0]), p ); await native({ type: 'probeResult', url: 'http://192.168.1.5:3000/', outcome: { kind: 'refused' }, canStart: false, }); await native({ type: 'localStatus', status: st16('running', { version: '1.49.0', outcome: { kind: 'ok', version: '1.49.0' }, found: [{ url: 'http://127.0.0.1:3000/', version: '1.49.0', state: 'running', isDefault: true }, elsewhere[1]], }), }); const alsoBox = await page.evaluate(() => { const r = (sel) => document.querySelector(sel).getBoundingClientRect(); return { open: r('#local-open').bottom, also: r('#local-also').top, rows: document.querySelectorAll('#local-also .found').length, head: document.querySelector('#local-also-head').textContent, button: document.querySelector('#local-also .found button').className, }; }); check( 'running with another server on this Mac: it is listed under Open, as a secondary action', (await visible('#local-open')) && alsoBox.also >= alsoBox.open && alsoBox.rows === 1 && alsoBox.head === 'Also running on this Mac' && alsoBox.button === 'secondary' && !(await visible('#local-found')), alsoBox ); await native({ type: 'localStatus', status: st16('stopped', { url: 'https://127.0.0.1:3000/', service: true }) }); check( 'stopped with HTTPS: no Start (the app refuses its certificate), explained', !(await visible('#local-start')) && (await page.textContent('#local-detail')).includes('--https') ); await native({ type: 'localStatus', status: st16('stopped', { service: true, daemon: true }) }); check( 'stopped under a system LaunchDaemon: no Start, the admin command shown', !(await visible('#local-start')) && (await page.textContent('#local-detail')).includes('launchctl kickstart') ); await page.goto('http://app.test/index.html?manual=1'); await native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0' }); n0 = (await all()).length; await native({ type: 'localStatus', status: st16('notInstalled', { installRunning: true, installPort: 3002 }) }); check( 'an install still running (page shown again): waits for it, no Install', (await visible('#local-stop')) && !(await visible('#local-install')) ); await page.clock.runFor(3100); n0 = (await all()).length; await native({ type: 'localStatus', status: st16('running', { url: 'http://127.0.0.1:3002/', installRunning: true, installPort: 3002, outcome: { kind: 'ok', version: '1.47.0' }, }), }); p = (await all()).slice(n0); check( 'and opens it on the port the installer took', p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://127.0.0.1:3002/', p ); await native({ type: 'probeResult', url: 'http://127.0.0.1:3002/', outcome: { kind: 'timeout' }, canStart: true }); await native({ type: 'localStatus', status: st16('notInstalled', { installRunning: true }) }); await page.click('#local-stop'); await native({ type: 'localStatus', status: st16('notInstalled', { installRunning: true }) }); check( 'after Stop waiting, a running install shows as such and never offers Install', !(await visible('#local-stop')) && !(await visible('#local-install')) && (await page.textContent('#local-title')).includes('being installed') ); await native({ type: 'error', message: 'Codeman Desktop could not read the saved password.', needsPassword: true }); check( 'an unreadable saved password shows the login fields (review #8)', (await visible('#login')) && (await visible('#form')) ); await page.click('#tab-local'); await native({ type: 'localStatus', status: st16('other', { installed: false, outcome: { kind: 'notCodeman', status: 404 } }), }); check( 'a dev server on 3000 with no Codeman: Install still offered, says why', (await visible('#local-install')) && (await page.textContent('#local-detail')).includes('next free port') ); await native({ type: 'localStatus', status: st16('other', { installed: true, outcome: { kind: 'tlsUntrusted' } }) }); check( 'installed but unusable (self-signed): explained, Check again only', (await page.textContent('#local-title')).includes('not trusted') && (await visible('#local-check')) && !(await visible('#local-install')) && !(await visible('#local-open')) ); await native({ type: 'localStatus', status: st16('running', { url: 'http://127.0.0.1:3000//' }), }); check( 'the local URL is shown as text', (await page.evaluate(() => window.__xss2)) === undefined && (await page.textContent('#local-detail')).includes(' !/eval/i.test(t)).length === 0, cspViolations ); await ctx.close(); } // Layout that does not move or cut the page off, the status line under the // action, the deferred look at this Mac, and fields too long for the app. async function layoutAndLimits(browser: Browser) { const open = async (viewport, query) => { const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await serveConnect(page); await page.addInitScript(IPC_STUB); await page.goto('http://app.test/index.html' + (query || '')); const native = (msg) => page.evaluate((m) => window.__codemanDesktop.onNative(m), msg); const posted = (cmd) => page.evaluate((c) => window.__posted.filter((m) => m.cmd === c), cmd); const top = (sel) => page.evaluate((s) => Math.round(document.querySelector(s).getBoundingClientRect().top), sel); return { ctx, page, errors, native, posted, top }; }; const SAVED = [ { url: 'https://box.tail.ts.net/', username: 'admin', hasSavedPassword: true }, { url: 'http://192.168.1.20:3000/' }, ]; const localStatus = (s) => ({ type: 'localStatus', status: Object.assign( { url: 'http://127.0.0.1:3000/', state: 'running', version: '1.48.0', service: true, daemon: false, installed: true, installComplete: true, installRunning: false, installPort: null, found: [], outcome: { kind: 'ok', version: '1.48.0' }, }, s ), }); // a. The header stays where it is while the card changes. let t = await open({ width: 1280, height: 820 }, '?manual=1'); const before = await t.top('header'); await t.native({ type: 'init', servers: SAVED, autoConnect: false, version: '0.1.0' }); const tops = [await t.top('header')]; await t.page.click('#tab-local'); tops.push(await t.top('header')); await t.native(localStatus({})); tops.push(await t.top('header')); await t.page.click('#tab-remote'); await t.page.click('#connect'); await t.native({ type: 'probeResult', url: SAVED[0].url, outcome: { kind: 'badCredentials' }, canStart: false }); tops.push(await t.top('header')); check( 'the header does not move between states', tops.every((y) => y === before), [before].concat(tops) ); check( 'a saved server with its login fits the default window without scrolling', await (async () => { await t.page.goto('http://app.test/index.html?manual=1'); await t.native({ type: 'init', servers: SAVED, autoConnect: false, version: '0.1.0' }); return t.page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight); })() ); check('layout: no page errors', t.errors.length === 0, t.errors); await t.ctx.close(); // b. The status line: hidden while empty, right under the action, above the install section. t = await open({ width: 1280, height: 820 }, '?manual=1'); await t.native({ type: 'init', servers: SAVED, autoConnect: false, version: '0.1.0' }); check('no status: the status row takes no room', !(await t.page.locator('#status-row').isVisible())); check('the install section shows on the Another machine tab', await t.page.locator('#elsewhere').isVisible()); await t.page.click('#connect'); await t.native({ type: 'probeResult', url: SAVED[0].url, outcome: { kind: 'timeout' }, canStart: false }); const order = await t.page.evaluate(() => { const r = (s) => document.querySelector(s).getBoundingClientRect(); return { connectBottom: r('#connect').bottom, statusTop: r('#status-row').top, statusBottom: r('#status-row').bottom, installTop: r('#elsewhere').top, }; }); check( 'an error sits between Connect and the install section', order.connectBottom <= order.statusTop && order.statusBottom <= order.installTop, order ); await t.page.click('#tab-local'); check('the install section is hidden on the This Mac tab', !(await t.page.locator('#elsewhere').isVisible())); check('switching tabs clears the status row', !(await t.page.locator('#status-row').isVisible())); await t.ctx.close(); // c. A window smaller than the page scrolls from the top, and a new status is brought into view. t = await open({ width: 480, height: 360 }, '?manual=1'); await t.native({ type: 'init', servers: SAVED, autoConnect: false, version: '0.1.0' }); const small = await t.page.evaluate(() => ({ headerTop: document.querySelector('header').getBoundingClientRect().top, scrolls: document.documentElement.scrollHeight > window.innerHeight, y: window.scrollY, })); check( 'small window: the page starts at its top and scrolls', small.headerTop >= 0 && small.scrolls && small.y === 0, small ); await t.page.click('#connect'); await t.native({ type: 'probeResult', url: SAVED[0].url, outcome: { kind: 'badCredentials' }, canStart: false }); const seen = await t.page.evaluate(() => { const r = document.querySelector('#status-row').getBoundingClientRect(); return { top: r.top, bottom: r.bottom, h: window.innerHeight }; }); check('small window: an error is scrolled into view', seen.top >= 0 && seen.top < seen.h, seen); await t.page.evaluate(() => window.scrollTo(0, 0)); check( 'small window: the top is still reachable', (await t.page.evaluate(() => document.querySelector('header').getBoundingClientRect().top)) >= 0 ); await t.ctx.close(); // d. An error that needs the login fields keeps its text (the tab switch used to wipe it). t = await open({ width: 1280, height: 820 }, '?manual=1'); await t.native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/', username: 'admin', hasSavedPassword: true }], autoConnect: false, version: '0.1.0', }); await t.native(localStatus({})); await t.page.click('#local-open'); await t.native({ type: 'error', message: 'The saved password could not be read from the Keychain. Enter it again.', needsPassword: true, }); check( 'needsPassword error: the Another machine tab with the login fields', (await t.page.locator('#form').isVisible()) && (await t.page.locator('#login').isVisible()) ); check( 'needsPassword error: its text is still shown', (await t.page.textContent('#status')).includes('could not be read from the Keychain'), await t.page.textContent('#status') ); await t.ctx.close(); // e. An automatic connect to the Codeman on this Mac does not look at this Mac first. t = await open({ width: 1280, height: 820 }, ''); await t.native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, version: '0.1.0' }); check('auto-connect (local): the connect goes out', (await t.posted('connect')).length === 1); check( 'auto-connect (local): no look at this Mac yet', (await t.posted('localStatus')).length === 0, await t.posted('localStatus') ); await t.native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'refused' }, canStart: true }); check('auto-connect (local): a failed connect looks at this Mac', (await t.posted('localStatus')).length === 1); await t.ctx.close(); t = await open({ width: 1280, height: 820 }, ''); await t.native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, version: '0.1.0' }); await t.page.click('#cancel'); check( 'auto-connect (local): Cancel looks at this Mac', (await t.posted('localStatus')).length === 1 && !(await t.page.locator('#status-row').isVisible()) ); await t.page.click('#cancel', { force: true }).catch(() => {}); check('a second look is not started while one runs', (await t.posted('localStatus')).length === 1); await t.ctx.close(); t = await open({ width: 1280, height: 820 }, ''); await t.native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, version: '0.1.0' }); await t.native({ type: 'error', message: 'Enter a valid address.' }); check('auto-connect (local): an error looks at this Mac', (await t.posted('localStatus')).length === 1); await t.ctx.close(); t = await open({ width: 1280, height: 820 }, ''); await t.native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: false, version: '0.1.0' }); check( 'no auto-connect: the This Mac tab looks at once', (await t.posted('localStatus')).length === 1 && (await t.posted('connect')).length === 0 ); await t.ctx.close(); // f. What the app would drop without an answer is refused on the page. t = await open({ width: 1280, height: 820 }, '?manual=1'); await t.native({ type: 'init', servers: [], autoConnect: false, version: '0.1.0' }); await t.page.click('#tab-remote'); await t.page.fill('#url', 'http://h/' + 'a'.repeat(4096)); await t.page.click('#connect'); check( 'an over-long address is refused with a reason', (await t.posted('connect')).length === 0 && (await t.page.textContent('#status')).includes('too long') && !(await t.page.isDisabled('#connect')) ); await t.page.fill('#url', 'http://h/' + 'a'.repeat(4096 - 'http://h/'.length)); await t.page.click('#connect'); check('an address of exactly 4096 bytes goes out', (await t.posted('connect')).length === 1); await t.native({ type: 'probeResult', url: 'http://h/', outcome: { kind: 'needsLogin' }, canStart: false }); await t.page.fill('#password', '€'.repeat(342)); await t.page.click('#connect'); check( 'a password over 1024 bytes (342 x 3) is refused with a reason', (await t.posted('connect')).length === 1 && (await t.page.textContent('#status')).includes('too long'), await t.page.textContent('#status') ); await t.page.fill('#password', '€'.repeat(341)); await t.page.click('#connect'); check('a password of 1023 bytes goes out', (await t.posted('connect')).length === 2); check( 'the SSH field cannot hold more than the app accepts', (await t.page.getAttribute('#ssh', 'maxlength')) === '300' ); // The byte count is the page's own: 4-byte characters (a surrogate pair each) and 2-byte ones. await t.native({ type: 'probeResult', url: 'http://h/', outcome: { kind: 'badCredentials' }, canStart: false }); await t.page.fill('#password', '😀'.repeat(257)); await t.page.click('#connect'); check( '257 four-byte characters (1028 bytes) are refused', (await t.posted('connect')).length === 2 && (await t.page.textContent('#status')).includes('too long') ); await t.page.fill('#password', '😀'.repeat(256)); await t.page.click('#connect'); check('256 four-byte characters (1024 bytes) go out', (await t.posted('connect')).length === 3); await t.native({ type: 'probeResult', url: 'http://h/', outcome: { kind: 'badCredentials' }, canStart: false }); await t.page.fill('#password', 'é'.repeat(513)); await t.page.click('#connect'); check( '513 two-byte characters (1026 bytes) are refused', (await t.posted('connect')).length === 3 && (await t.page.textContent('#status')).includes('too long') ); await t.page.fill('#password', 'é'.repeat(512)); await t.page.click('#connect'); check('512 two-byte characters (1024 bytes) go out', (await t.posted('connect')).length === 4); check('limits: no page errors', t.errors.length === 0, t.errors); await t.ctx.close(); } // Double clicks, keys, the tab an answer belongs to, a server that is still // starting, and what a screen reader hears. async function clicksKeysAndStates(browser: Browser) { const open = async (viewport, query) => { const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await serveConnect(page); await page.addInitScript(IPC_STUB); await page.goto('http://app.test/index.html' + (query || '')); const native = (msg) => page.evaluate((m) => window.__codemanDesktop.onNative(m), msg); const posted = (cmd) => page.evaluate((c) => window.__posted.filter((m) => m.cmd === c), cmd); return { ctx, page, errors, native, posted }; }; const SIX = ['a', 'b', 'c', 'd', 'e', 'f'].map((n) => ({ url: 'http://' + n + '.example:3000/' })); const localStatus = (s) => ({ type: 'localStatus', status: Object.assign( { url: 'http://127.0.0.1:3000/', state: 'running', version: '1.48.0', service: true, daemon: false, installed: true, installComplete: true, installRunning: false, installPort: null, found: [], outcome: { kind: 'ok', version: '1.48.0' }, }, s ), }); const WIDE = { width: 1280, height: 820 }; // a. The second click of a double click never lands on another control. let t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: SIX, autoConnect: false, version: '0.1.0' }); // The app answers `forget` with the new list (main.rs), so the chips are // rebuilt between the two clicks of a double click. await t.page.evaluate(() => { const push = window.__posted.push.bind(window.__posted); window.__posted.push = (m) => { const n = push(m); if (m.cmd === 'forget') { const left = Array.from(document.querySelectorAll('#recent .chip')) .map((chip) => chip.firstChild.title.replace('Connect to ', '')) .filter((url) => url !== m.url) .map((url) => ({ url })); window.__codemanDesktop.onNative({ type: 'servers', servers: left }); } return n; }; }); const firstX = t.page.locator('#recent .chip .x').first(); const box = await firstX.boundingBox(); await t.page.mouse.dblclick(box.x + box.width / 2, box.y + box.height / 2); check( 'double click on a Forget: one forget, no connect to the server that slid under the pointer', (await t.posted('forget')).length === 1 && (await t.posted('connect')).length === 0, { forget: await t.posted('forget'), connect: await t.posted('connect') } ); check('double click on a Forget: five servers left', (await t.page.locator('#recent .chip').count()) === 5); // A label still takes both clicks of a double click (it stays under the pointer). const autoBefore = await t.page.isChecked('#auto'); const label = await t.page.locator('label.check:has(#auto)').boundingBox(); await t.page.mouse.dblclick(label.x + 40, label.y + label.height / 2); check( 'double click on a checkbox label still toggles twice', (await t.page.isChecked('#auto')) === autoBefore && (await t.posted('setAutoConnect')).length === 2, await t.posted('setAutoConnect') ); // The keyboard is not touched by the guard. await t.page.focus('#tab-remote'); await t.page.keyboard.press('ArrowLeft'); check('arrow keys still switch tabs', await t.page.locator('#pane-local').isVisible()); await t.page.keyboard.press('ArrowRight'); check('arrow keys switch back', await t.page.locator('#form').isVisible()); await t.ctx.close(); // b. Looking again keeps what was found on screen. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [], autoConnect: false, version: '0.1.0' }); await t.native( localStatus({ state: 'notInstalled', installed: false, service: false, installComplete: false, version: null, outcome: { kind: 'refused' }, }) ); const cardBefore = await t.page.evaluate(() => Math.round(document.querySelector('.card').getBoundingClientRect().height) ); await t.page.click('#local-check'); const during = await t.page.evaluate(() => ({ card: Math.round(document.querySelector('.card').getBoundingClientRect().height), title: document.querySelector('#local-title').textContent, install: !document.querySelector('#local-install').hidden, checkDisabled: document.querySelector('#local-check').disabled, dot: document.querySelector('#local-dot').className, })); check( 'Check again: the card keeps its size and its text', during.card === cardBefore && /not installed/.test(during.title) && during.install, { cardBefore, during } ); check( 'Check again: the dot shows the look and the button waits', /busy/.test(during.dot) && during.checkDisabled, during ); check( 'Check again asks for the deep look', (await t.posted('localStatus')).some((m) => m.deep === true) ); await t.native(localStatus({})); check( 'Check again: the new status replaces the old one', (await t.page.textContent('#local-title')).includes('is running') && !(await t.page.isDisabled('#local-check')) ); await t.ctx.close(); // c. Return and Esc. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [], autoConnect: false, version: '0.1.0' }); await t.native(localStatus({})); await t.page.keyboard.press('Enter'); check( 'Return on This Mac presses Open Codeman', (await t.posted('connect')).length === 1 && (await t.posted('connect'))[0].url === 'http://127.0.0.1:3000/' ); check('the connect shows Cancel', await t.page.locator('#cancel').isVisible()); await t.page.keyboard.press('Escape'); check( 'Esc cancels the connect', (await t.posted('cancel')).length === 1 && !(await t.page.locator('#cancel').isVisible()) ); await t.page.keyboard.press('Escape'); check('Esc with nothing to cancel does nothing', (await t.posted('cancel')).length === 1); await t.native( localStatus({ state: 'notInstalled', installed: false, service: false, installComplete: false, version: null, outcome: { kind: 'refused' }, }) ); await t.page.keyboard.press('Enter'); check( 'Return never starts an install', (await t.posted('installLocal')).length === 0 && (await t.page.locator('#local-install').isVisible()) ); await t.native( localStatus({ state: 'runningElsewhere', version: null, outcome: { kind: 'refused' }, found: [ { url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false }, { url: 'http://127.0.0.1:5000/', version: '1.49.0', state: 'running', isDefault: false }, ], }) ); await t.page.keyboard.press('Enter'); check( 'Return connects to the server found on this Mac when that is the main action', (await t.posted('connect')).length === 2 && (await t.posted('connect'))[1].url === 'http://127.0.0.1:5000/', await t.posted('connect') ); await t.page.keyboard.press('Escape'); await t.native(localStatus({ state: 'stopped', version: null, outcome: { kind: 'refused' } })); await t.page.focus('#local-check'); await t.page.keyboard.press('Enter'); check( 'Return on a focused button presses that button, not the main one', (await t.posted('startServer')).length === 0 && (await t.posted('localStatus')).some((m) => m.deep === true), { start: await t.posted('startServer'), looks: await t.posted('localStatus') } ); await t.ctx.close(); // d. A connect answer goes to the tab that asked. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [{ url: 'http://build-box:3000/' }], autoConnect: false, version: '0.1.0' }); await t.page.click('#connect'); await t.page.click('#tab-local'); const looksBefore = (await t.posted('localStatus')).length; await t.native({ type: 'probeResult', url: 'http://build-box:3000/', outcome: { kind: 'refused' }, canStart: false }); check( 'a remote failure is shown on Another machine, even after a look at This Mac', (await t.page.locator('#form').isVisible()) && (await t.page.textContent('#status')).includes('Nothing is answering') ); check('a remote failure does not look at this Mac again', (await t.posted('localStatus')).length === looksBefore, { looksBefore, now: (await t.posted('localStatus')).length, }); await t.ctx.close(); // e. The login block never claims a saved password for another server. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [{ url: 'https://box.tail.ts.net/', username: 'admin', hasSavedPassword: true }], autoConnect: false, version: '0.1.0', }); check( 'a saved server shows its saved password', (await t.page.getAttribute('#password', 'placeholder')) === 'saved in Keychain' ); await t.page.fill('#url', 'http://192.168.1.50:3000'); check( 'another address typed: no saved password claimed', (await t.page.getAttribute('#password', 'placeholder')) === '' ); await t.page.fill('#url', 'https://box.tail.ts.net/'); check( 'the saved address typed again: its saved password is back', (await t.page.getAttribute('#password', 'placeholder')) === 'saved in Keychain' ); await t.native({ type: 'servers', servers: [] }); check( 'the server forgotten: no saved password claimed', (await t.page.getAttribute('#password', 'placeholder')) === '' ); await t.ctx.close(); // f. Opening keeps the spinner; a long token stays inside the card; the status text can be selected. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [{ url: 'http://build-box:3000/' }], autoConnect: false, version: '0.1.0' }); await t.page.click('#connect'); await t.native({ type: 'opening', version: '1.48.0' }); check('Opening Codeman shows the spinner', await t.page.locator('#spinner').isVisible()); await t.native({ type: 'error', message: 'Could not open https://sso.example.com/login?return=' + 'x'.repeat(400) }); const fit = await t.page.evaluate(() => ({ right: document.querySelector('#status-text').getBoundingClientRect().right, card: document.querySelector('.card').getBoundingClientRect().right, sideways: document.documentElement.scrollWidth > window.innerWidth, select: getComputedStyle(document.querySelector('#status-text')).userSelect, })); check('a long unbroken token wraps inside the card', fit.right <= fit.card && !fit.sideways, fit); check('the status text can be selected for a bug report', fit.select === 'text', fit.select); await t.ctx.close(); // g. A login over plain http to another machine says so; not for this Mac, not for https. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [], autoConnect: false, version: '0.1.0' }); await t.page.click('#tab-remote'); const ask = async (url) => { await t.page.fill('#url', url); await t.page.click('#connect'); await t.native({ type: 'probeResult', url: url, outcome: { kind: 'needsLogin' }, canStart: false }); return t.page.textContent('#status'); }; check( 'http to another machine: the login notice says unencrypted', (await ask('http://build-box:3000/')).includes('unencrypted') ); check('https: no such notice', !(await ask('https://box.tail.ts.net/')).includes('unencrypted')); check('this Mac over http: no such notice', !(await ask('http://127.0.0.1:3000/')).includes('unencrypted')); await t.ctx.close(); // h. The SSH install: what the app would drop is refused here, and a failure is shown on its tab. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [], autoConnect: false, version: '0.1.0' }); await t.page.click('#tab-remote'); await t.page.click('#elsewhere summary'); await t.page.click('#ssh-install'); check( 'an empty SSH login is refused on the page', (await t.posted('installRemote')).length === 0 && (await t.page.textContent('#status')).includes('user@host') && !(await t.page.isDisabled('#ssh-install')) ); await t.page.fill('#ssh', 'kay@build-box'); await t.page.click('#ssh-install'); check('a normal SSH login goes out', (await t.posted('installRemote')).length === 1); await t.page.click('#tab-local'); await t.native({ type: 'installFailed', remote: true, message: 'Could not open Terminal.' }); check( 'a failed SSH install is shown on Another machine', (await t.page.locator('#form').isVisible()) && (await t.page.textContent('#status')).includes('Could not open Terminal') && !(await t.page.isDisabled('#ssh-install')) ); check('ssh install: no page errors', t.errors.length === 0, t.errors); await t.ctx.close(); // j. A Codeman that is still starting: shown as that, looked at again, and opened if that was asked. t = await open(WIDE, ''); await t.native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, version: '0.1.0' }); await t.native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'refused' }, canStart: true }); await t.native(localStatus({ state: 'starting', version: null, outcome: { kind: 'refused' } })); check( 'starting: the tab says so and offers no Start', (await t.page.textContent('#local-title')).includes('is starting') && !(await t.page.locator('#local-start').isVisible()) && /busy/.test(await t.page.getAttribute('#local-dot', 'class')) ); const looks0 = (await t.posted('localStatus')).length; await t.page.waitForTimeout(1250); const again = await t.posted('localStatus'); check( 'starting: the page looks again by itself, the quick way', again.length === looks0 + 1 && again[again.length - 1].quick === true, again ); await t.native(localStatus({})); check( 'starting, then up: the connect that was refused is made again', (await t.posted('connect')).length === 2 && (await t.posted('connect'))[1].url === 'http://127.0.0.1:3000/' ); await t.native({ type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'refused' }, canStart: true }); await t.native(localStatus({ state: 'stopped', version: null, outcome: { kind: 'refused' } })); await t.native(localStatus({})); check( 'a server that was stopped, not starting, is never opened by itself', (await t.posted('connect')).length === 2 && (await t.page.locator('#local-open').isVisible()) ); await t.ctx.close(); t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [], autoConnect: false, version: '0.1.0' }); await t.native(localStatus({ state: 'starting', version: null, outcome: { kind: 'refused' } })); await t.native(localStatus({})); check( 'starting with nobody asking to open: Open is offered, nothing connects', (await t.posted('connect')).length === 0 && (await t.page.locator('#local-open').isVisible()) ); await t.ctx.close(); // k. Two spellings of one server are one server: the page guesses the scheme as the app does. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [], autoConnect: false, version: '0.1.0' }); await t.page.click('#tab-remote'); const sameAfterStart = async (typed, started) => { await t.page.fill('#url', typed); await t.page.click('#connect'); await t.native({ type: 'probeResult', url: started, outcome: { kind: 'refused' }, canStart: true }); const before = (await t.posted('connect')).length; await t.page.click('#start'); await t.native({ type: 'serverStartResult', ok: true, output: '', url: started }); const sent = (await t.posted('connect')).slice(before); await t.native({ type: 'probeResult', url: started, outcome: { kind: 'refused' }, canStart: false }); return sent.length === 1; }; check( 'a tailnet name alone is the https server', await sameAfterStart('box.tail.ts.net', 'https://box.tail.ts.net/') ); check( 'a tailnet name with a port is the http server', await sameAfterStart('box.tail.ts.net:3000', 'http://box.tail.ts.net:3000/') ); check( 'a tailnet name with a port is not the https server', !(await sameAfterStart('box.tail.ts.net:3000', 'https://box.tail.ts.net:3000/')) ); await t.ctx.close(); // i. Screen readers: the This Mac result is a live region, Forget buttons are named. t = await open(WIDE, '?manual=1'); await t.native({ type: 'init', servers: [{ url: 'http://build-box:3000/' }], autoConnect: false, version: '0.1.0' }); check( 'the This Mac result is announced', (await t.page.getAttribute('#local-title >> xpath=..', 'role')) === 'status' ); check( 'a Forget button is named after its server', (await t.page.getAttribute('#recent .chip .x', 'aria-label')) === 'Forget build-box:3000' ); check('the recent servers are a labelled group', (await t.page.getAttribute('#recent', 'role')) === 'group'); await t.ctx.close(); } // A WebKit without class static blocks (older than Safari 16.4), which // Codeman's terminal needs: the page says so and never connects. async function tooOld(browser: Browser, how: 'setter' | 'syntax') { const ctx = await browser.newContext({ viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.name + ': ' + e.message)); if (how === 'syntax') { // The probe script made unparseable at the token an older parser rejects. const probe = 'void class { static { window.__codemanStaticBlocks = true; } };'; if (!html.includes(probe)) throw new Error('probe script not found in connect.html'); await serveConnect(page, html.replace(probe, 'void class { static @{ window.__codemanStaticBlocks = true; } };')); } else { // The static block's assignment lands in a setter that drops it, as if it never ran. await page.addInitScript(() => Object.defineProperty(window, '__codemanStaticBlocks', { get: function () { return undefined; }, set: function () {}, configurable: false, }) ); await serveConnect(page); } await page.addInitScript(IPC_STUB); const posted = () => page.evaluate(() => window.__posted.filter((m) => m.cmd !== 'localStatus')); const native = (msg) => page.evaluate((m) => window.__codemanDesktop.onNative(m), msg); const tag = how === 'syntax' ? ' (probe is a SyntaxError)' : ' (static block never ran)'; await page.goto('http://app.test/index.html'); check('too old: still posts ready' + tag, JSON.stringify(await posted()) === '[{"cmd":"ready"}]', await posted()); check( 'too old: the notice is shown, verbatim' + tag, (await page.locator('#webkit').isVisible()) && (await page.textContent('#webkit')) === WEBKIT_NOTICE, await page.textContent('#webkit') ); check('too old: Connect disabled at load' + tag, await page.isDisabled('#connect')); await native({ type: 'init', servers: [ { url: 'http://127.0.0.1:3000/', username: 'admin', hasSavedPassword: true }, { url: 'http://10.0.0.5:3000/' }, ], autoConnect: true, startFullscreen: false, version: '0.1.0', }); check('too old: init with auto-connect does not connect' + tag, (await posted()).length === 1, await posted()); await native({ type: 'localStatus', status: { url: 'http://127.0.0.1:3000/', state: 'notInstalled', version: null, service: false, installed: false, installComplete: false, outcome: { kind: 'refused' }, }, }); check( 'too old: This Mac offers Install but it is disabled' + tag, (await page.locator('#local-install').isVisible()) && (await page.isDisabled('#local-install')) ); await native({ type: 'localStatus', status: { url: 'http://127.0.0.1:3000/', state: 'running', version: '1.47.0', service: true, installed: true, installComplete: true, outcome: { kind: 'ok', version: '1.47.0' }, }, }); check( 'too old: Open is disabled too' + tag, (await page.locator('#local-open').isVisible()) && (await page.isDisabled('#local-open')) ); await page.click('#tab-remote'); check( 'too old: Connect still disabled after init, notice still shown' + tag, (await page.isDisabled('#connect')) && (await page.locator('#webkit').isVisible()) ); check( 'too old: chips cannot connect, forget still works' + tag, await page.evaluate(() => Array.from(document.querySelectorAll('#recent button')).every((b) => b.disabled === !b.classList.contains('x')) ) ); let n0 = (await posted()).length; await page.evaluate(() => { document.getElementById('form').requestSubmit(); const b = document.querySelector('.chip button'); b.disabled = false; b.click(); }); await page.focus('#url'); await page.keyboard.press('Enter'); check( 'too old: submit, Enter and a re-enabled chip post nothing' + tag, (await posted()).length === n0, (await posted()).slice(n0) ); for (const m of [ { type: 'error', message: 'x' }, { type: 'probeResult', url: 'http://127.0.0.1:3000/', outcome: { kind: 'maybeHttps' }, canStart: false }, { type: 'serverStartResult', ok: true, output: '', url: 'http://127.0.0.1:3000/' }, ]) { await native(m); } check( 'too old: error, probe and start results keep Connect disabled, offer no Use button and connect nothing' + tag, (await page.isDisabled('#connect')) && !(await page.locator('#suggest').isVisible()) && (await page.isDisabled('#suggest')) && (await posted()).length === n0 && (await page.locator('#webkit').isVisible()), (await posted()).slice(n0) ); await page.click('#auto'); await page.click('.chip button.x'); const p = (await posted()).slice(n0); check( 'too old: preferences and forget still reach the app' + tag, p.length === 2 && p[0].cmd === 'setAutoConnect' && p[1].cmd === 'forget', p ); if (how === 'syntax') { check( "too old: the only page error is the probe's SyntaxError" + tag, errors.length === 1 && /^SyntaxError/.test(errors[0]), errors ); } else { check('too old: no page errors' + tag, errors.length === 0, errors); } await ctx.close(); } describe('Codeman Desktop connect page', () => { let browser: Browser; beforeAll(async () => { browser = await chromium.launch({ headless: true }); }); afterAll(async () => { await browser?.close(); }); const walk = (name: string, steps: (browser: Browser) => Promise) => it( name, async () => { failed.length = 0; await steps(browser); expect(failed).toEqual([]); }, 180_000 ); walk('every state of both tabs, from first run to an install', everyState); walk('a layout that holds still, and fields too long for the app', layoutAndLimits); walk('double clicks, keys, tabs, a starting server and screen readers', clicksKeysAndStates); walk('a WebKit too old for Codeman, found by a setter', (b) => tooOld(b, 'setter')); walk('a WebKit too old for Codeman, found by a syntax error', (b) => tooOld(b, 'syntax')); });