From 92797c26193df2d050bc0ec296debb92f529e7f9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 03:54:49 +0100 Subject: [PATCH] test(desktop): unit tests for main.rs's decisions and a browser test for the connect page main.rs was the only source file without tests, and it held three decisions with real failure modes: - Which login a connect goes out with. The choice was a closure body inside the probe worker; it is now choose_login, tested for every row: typed wins, then the Keychain, then the local Codeman's own login, which is used only when no saved password was expected and only for the same username. - What the Keychain keeps afterwards (update_keychain): saved or deleted by Remember, never a copy of the local server's login, never a password the server wants changed, and the old user's item deleted before the new save. The in-memory Keychain store moves out of credentials' test module so other test modules can use it. - When a commit in the main window means the server page is loaded. The connect page had only substring checks in the repository; the checks that drove it lived in a scratch script. They are now test/desktop-connect-page.browser.test.ts: Chromium, the page's real CSP, a stubbed native side, five ordered walks through every state of both tabs (first run, logins, failures, redirects, Start, the install wait, the SSH install, layout, double clicks, keys, a starting server, a WebKit too old). Listed in BROWSER_TEST_GLOBS, so the nightly browser suite runs it and `npm test` does not. Also pins the install one-liner the page shows to the one Copy copies. Co-Authored-By: Claude Fable 5.1 --- config/test-suites.ts | 1 + packages/desktop/README.md | 6 + packages/desktop/src/assets.rs | 9 + packages/desktop/src/credentials.rs | 32 +- packages/desktop/src/main.rs | 217 ++- test/desktop-connect-page.browser.test.ts | 1795 +++++++++++++++++++++ 6 files changed, 2030 insertions(+), 30 deletions(-) create mode 100644 test/desktop-connect-page.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 49812b3a..e6f95e04 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -60,6 +60,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/fullscreen-tab-switch-capture.browser.test.ts', 'test/terminal-touch-swipe-axis.browser.test.ts', 'test/phone-press-feedback.browser.test.ts', + 'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception ]; /** diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 06dbc9c8..cf7e1c17 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -55,6 +55,12 @@ cargo test # unit tests open dist/Codeman.app ``` +The connect page has its own test, which needs no Mac: from the repository +root, `npm run test:browser -- test/desktop-connect-page.browser.test.ts` drives +`assets/connect.html` in Chromium through every state of both tabs, under the +Content-Security-Policy the app serves it with. It is part of the nightly +browser suite, not of `npm test`. + The script builds with `--locked` against the committed `Cargo.lock`, targets the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a diff --git a/packages/desktop/src/assets.rs b/packages/desktop/src/assets.rs index 4cdb0cee..65db1b0f 100644 --- a/packages/desktop/src/assets.rs +++ b/packages/desktop/src/assets.rs @@ -295,6 +295,15 @@ mod tests { } } + /// Copy puts setup::INSTALL_ONE_LINER on the clipboard, and the page shows + /// the command as text of its own: the two have to be the same command. + #[test] + fn connect_page_shows_the_command_that_copy_copies() { + let page = String::from_utf8_lossy(CONNECT_HTML); + let shown = format!("{}", crate::setup::INSTALL_ONE_LINER); + assert!(page.contains(&shown), "{shown}"); + } + #[test] fn toast_escapes_file_names() { let js = download_toast_script("a\"); alert(1); (\".txt", true); diff --git a/packages/desktop/src/credentials.rs b/packages/desktop/src/credentials.rs index 552f560a..5ac8d1c9 100644 --- a/packages/desktop/src/credentials.rs +++ b/packages/desktop/src/credentials.rs @@ -189,9 +189,11 @@ mod imp { } } +/// A Keychain on an in-memory store, for these tests and for the tests of the +/// code that uses a Keychain (main.rs). #[cfg(test)] -mod tests { - use super::{start, Backend, Job, Keychain}; +pub(crate) mod memory { + use super::{start, Backend, Keychain}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -200,7 +202,7 @@ mod tests { /// purpose: a queue that did not keep order would let the next job /// overtake it, and the tests would see that. #[derive(Clone, Default)] - struct Memory { + pub(crate) struct Memory { items: Arc>>, log: Arc>>, } @@ -209,7 +211,8 @@ mod tests { fn note(&self, entry: String) { self.log.lock().unwrap().push(entry); } - fn log(&self) -> Vec { + /// The jobs the store ran, in order. + pub(crate) fn log(&self) -> Vec { self.log.lock().unwrap().clone() } } @@ -234,6 +237,20 @@ mod tests { } } + /// A worker on a fresh store, and the store to look into. + pub(crate) fn keychain() -> (Keychain, Memory) { + let memory = Memory::default(); + let (keychain, worker) = start(Box::new(memory.clone())); + assert!(worker.is_some(), "the worker thread starts"); + (keychain, memory) + } +} + +#[cfg(test)] +mod tests { + use super::memory::{keychain, Memory}; + use super::{start, Backend, Job, Keychain}; + /// A store whose load fails the way a crashed worker would. struct Crashes; @@ -247,13 +264,6 @@ mod tests { fn delete(&mut self, _account: &str) {} } - fn keychain() -> (Keychain, Memory) { - let memory = Memory::default(); - let (keychain, worker) = start(Box::new(memory.clone())); - assert!(worker.is_some(), "the worker thread starts"); - (keychain, memory) - } - #[test] fn account_names_are_per_user_and_server() { assert_eq!( diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 2e817da5..8c70d836 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -1335,25 +1335,8 @@ impl App { .as_ref() .and_then(|u| keychain.load_blocking(&credentials::account(&key, u))), }; - let (user, password, source) = match typed_password { - Some(p) => (user, Some(p), PasswordSource::Typed), - None => match saved { - Some(p) => (user, Some(p), PasswordSource::Keychain), - // The Codeman on this Mac keeps its login where Codeman's - // own clients find it (setup::local_login: its launchd - // job, then ~/.codeman/.env), and only answers it at its - // own loopback address. Used when nothing was typed and - // the Keychain has nothing for this server, for the same - // username only, and never saved anywhere. - None if !expect_saved => match setup::local_login(&url) { - Some((u, p)) if user.as_deref().is_none_or(|current| current == u) => { - (Some(u), Some(p), PasswordSource::LocalServer) - } - _ => (user, None, PasswordSource::Absent), - }, - None => (user, None, PasswordSource::Absent), - }, - }; + let (user, password, source) = + choose_login(user, typed_password, saved, expect_saved, || setup::local_login(&url)); // Codeman's default username when only a password was given. let username = password .as_ref() @@ -1860,6 +1843,38 @@ impl App { } /// A login the probe just tried, and what the user asked for it. +/// The login a connect goes out with, and where its password came from. A +/// typed password wins, then the one the Keychain holds for this user. Last +/// comes the login of the Codeman on this Mac, which keeps it where its own +/// clients find it (setup::local_login: its launchd job, then +/// ~/.codeman/.env) and only answers it at its own loopback address. That one +/// is used only when the config expected no password in the Keychain +/// (`expect_saved`: one that should be there and is not is reported, not +/// papered over), only for the username in use when there is one, and it is +/// never saved anywhere. `local` is asked only then: it runs processes. +fn choose_login( + user: Option, + typed_password: Option, + saved: Option, + expect_saved: bool, + local: impl FnOnce() -> Option<(String, String)>, +) -> (Option, Option, PasswordSource) { + if let Some(password) = typed_password { + return (user, Some(password), PasswordSource::Typed); + } + if let Some(password) = saved { + return (user, Some(password), PasswordSource::Keychain); + } + if !expect_saved { + if let Some((local_user, password)) = local() { + if user.as_deref().is_none_or(|current| current == local_user) { + return (Some(local_user), Some(password), PasswordSource::LocalServer); + } + } + } + (user, None, PasswordSource::Absent) +} + struct ProbedLogin<'a> { /// The server, as the config and the Keychain accounts key it. server: &'a str, @@ -1939,3 +1954,167 @@ fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLo update.saved = saved; update } + +#[cfg(test)] +mod tests { + use super::*; + use crate::credentials::memory; + + fn none() -> Option<(String, String)> { + panic!("the local login must not be looked up here"); + } + + #[test] + fn the_login_a_connect_uses() { + let s = |v: &str| Some(v.to_string()); + let local = || Some(("admin".to_string(), "from-this-mac".to_string())); + + // A typed password wins over everything, and nothing else is asked. + assert_eq!( + choose_login(s("kay"), s("typed"), s("kept"), true, none), + (s("kay"), s("typed"), PasswordSource::Typed) + ); + // Then the Keychain's. + assert_eq!( + choose_login(s("kay"), None, s("kept"), true, none), + (s("kay"), s("kept"), PasswordSource::Keychain) + ); + // The Keychain should hold one and does not: reported as absent, the + // local login is not tried in its place. + assert_eq!( + choose_login(s("kay"), None, None, true, none), + (s("kay"), None, PasswordSource::Absent) + ); + // Nothing typed, nothing expected: the Codeman on this Mac's own + // login, for no username or the same one. + assert_eq!( + choose_login(None, None, None, false, local), + (s("admin"), s("from-this-mac"), PasswordSource::LocalServer) + ); + assert_eq!( + choose_login(s("admin"), None, None, false, local), + (s("admin"), s("from-this-mac"), PasswordSource::LocalServer) + ); + // Never for another user: the names are compared exactly. + assert_eq!( + choose_login(s("kay"), None, None, false, local), + (s("kay"), None, PasswordSource::Absent) + ); + assert_eq!( + choose_login(s("Admin"), None, None, false, local), + (s("Admin"), None, PasswordSource::Absent) + ); + // No local login either. + assert_eq!( + choose_login(None, None, None, false, || None), + (None, None, PasswordSource::Absent) + ); + } + + /// update_keychain for one probe answer; the log is what the Keychain did, + /// in order (the last load only waits for the queue to drain). + fn keychain_after(outcome: ProbeOutcome, login: ProbedLogin<'_>) -> (KeychainUpdate, Vec) { + let (keychain, store) = memory::keychain(); + let update = update_keychain(&keychain, &outcome, &login); + keychain.load_blocking("done"); + let mut log = store.log(); + assert_eq!(log.pop().as_deref(), Some("load done")); + (update, log) + } + + fn login<'a>( + previous_user: Option<&'a str>, + username: Option<&'a str>, + source: PasswordSource, + remember: bool, + ) -> ProbedLogin<'a> { + ProbedLogin { + server: "http://a/", + previous_user, + username, + password: (source != PasswordSource::Absent).then_some("secret"), + source, + remember, + } + } + + #[test] + fn what_the_keychain_keeps_after_a_connect() { + let ok = || ProbeOutcome::Ok { + version: "1.48.0".into(), + }; + let item = |user: &str| credentials::account("http://a/", user); + + // A typed password is saved when Remember is on, deleted when it is off. + let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, true)); + assert!(update.saved && update.deleted.is_empty()); + assert_eq!(log, [format!("save {}", item("kay"))]); + let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, false)); + assert!(!update.saved); + assert_eq!(update.deleted, ["kay"]); + assert_eq!(log, [format!("delete {}", item("kay"))]); + + // One that came from the Keychain is there already: nothing is written. + let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Keychain, true)); + assert!(update.saved && log.is_empty()); + + // The local Codeman's own login never gets a copy, Remember or not. + let (update, log) = keychain_after(ok(), login(None, Some("admin"), PasswordSource::LocalServer, true)); + assert!(!update.saved && update.deleted.is_empty() && log.is_empty()); + + // Another user than before: the old user's item goes FIRST, then the save. + let (update, log) = keychain_after(ok(), login(Some("old"), Some("new"), PasswordSource::Typed, true)); + assert!(update.saved); + assert_eq!(update.deleted, ["old"]); + assert_eq!( + log, + [format!("delete {}", item("old")), format!("save {}", item("new"))] + ); + + // A password the server wants changed is never kept. + let (update, log) = keychain_after( + ProbeOutcome::PasswordChangeRequired, + login(Some("kay"), Some("kay"), PasswordSource::Typed, true), + ); + assert!(!update.saved); + assert_eq!(update.deleted, ["kay"]); + assert_eq!(log, [format!("delete {}", item("kay"))]); + + // A login the server did not accept changes nothing. + for refused in [ + ProbeOutcome::BadCredentials, + ProbeOutcome::NeedsLogin, + ProbeOutcome::Refused, + ProbeOutcome::Timeout, + ] { + let (update, log) = keychain_after(refused, login(Some("old"), Some("new"), PasswordSource::Typed, true)); + assert!(!update.saved && update.deleted.is_empty() && log.is_empty()); + } + } + + #[test] + fn a_commit_from_the_loading_server_is_the_loaded_page() { + let server = Url::parse("https://box.ts.net/codeman/").unwrap(); + let loading = Phase::Loading(server.clone()); + assert_eq!( + loaded_on_commit(&loading, "https://box.ts.net/codeman/"), + Some(Phase::Loaded(server.clone())) + ); + // Any page of that origin counts: the server may redirect within it. + assert_eq!( + loaded_on_commit(&loading, "https://box.ts.net/codeman/session/abc"), + Some(Phase::Loaded(server.clone())) + ); + // Another origin, the connect page, or junk: still loading. + assert_eq!(loaded_on_commit(&loading, "https://other.ts.net/codeman/"), None); + assert_eq!(loaded_on_commit(&loading, "http://box.ts.net/codeman/"), None); + assert_eq!(loaded_on_commit(&loading, policy::CONNECT_PAGE), None); + assert_eq!(loaded_on_commit(&loading, "not a url"), None); + // Only a load under way can finish. + assert_eq!(loaded_on_commit(&Phase::Connect, "https://box.ts.net/codeman/"), None); + assert_eq!( + loaded_on_commit(&Phase::Loaded(server), "https://box.ts.net/codeman/"), + None + ); + } +} diff --git a/test/desktop-connect-page.browser.test.ts b/test/desktop-connect-page.browser.test.ts new file mode 100644 index 00000000..e9dfc870 --- /dev/null +++ b/test/desktop-connect-page.browser.test.ts @@ -0,0 +1,1795 @@ +/** + * @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 and the network option, no Open/Start', + (await visible('#local-install')) && + (await visible('#local-network-row')) && + !(await visible('#local-open')) && + !(await visible('#local-start')) && + (await page.textContent('#local-detail')).includes('Terminal') + ); + await page.click('#local-network'); + n0 = (await all()).length; + await page.dblclick('#local-install'); + p = (await all()).slice(n0); + check( + 'Install posts installLocal once, with the network choice', + p.length === 1 && p[0].cmd === 'installLocal' && p[0].network === true, + 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 page.click('#local-install'); + 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 page.click('#local-install'); + 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 page.click('#local-install'); + 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 + ); + await native({ type: 'localStatus', status: st16('runningElsewhere', { elsewhere: ['192.168.1.5:3000'] }) }); + check( + 'a server running elsewhere: no Start, no Install, says where', + !(await visible('#local-start')) && + !(await visible('#local-install')) && + !(await visible('#local-open')) && + (await page.textContent('#local-detail')).includes('192.168.1.5:3000') && + (await page.textContent('#local-title')).includes('already running') + ); + 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, + elsewhere: [], + 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, + elsewhere: [], + 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: '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')); +});