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'));
+});