mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
1002 lines
43 KiB
TypeScript
1002 lines
43 KiB
TypeScript
/**
|
||
* @fileoverview The graphical install's screens in the Codeman Desktop connect page, and the This
|
||
* Mac tab's rows for the Codeman servers the app found on this Mac.
|
||
*
|
||
* Runs the real packages/desktop/assets/connect.html in jsdom with a fake native side:
|
||
* `window.ipc` records what the page asks the app for, and `onNative` is what the app
|
||
* calls. The Rust side of the same messages is pinned in packages/desktop/src/policy.rs
|
||
* (`ipc_graphical_install_commands`), src/gui_install.rs and src/setup.rs (the `found` list:
|
||
* `a_found_server_row_says_what_answered`), which do not build on Linux CI, so the spelling of
|
||
* every message the page sends, and of what it reads, is asserted here.
|
||
*
|
||
* Port: N/A.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { JSDOM } from 'jsdom';
|
||
import { afterEach, describe, expect, it } from 'vitest';
|
||
|
||
const HTML = readFileSync(resolve(import.meta.dirname, '../packages/desktop/assets/connect.html'), 'utf8');
|
||
|
||
type Sent = { cmd: string; [key: string]: unknown };
|
||
type Cli = { id: string; label: string; installed: boolean; installable: boolean; docs: string };
|
||
|
||
const cli = (id: string, label: string, over: Partial<Cli> = {}): Cli => ({
|
||
id,
|
||
label,
|
||
installed: false,
|
||
installable: true,
|
||
docs: `https://example.org/${id}`,
|
||
...over,
|
||
});
|
||
|
||
const plan = (over: Record<string, unknown> = {}) => ({
|
||
schema: 1,
|
||
os: 'macos',
|
||
arch: 'arm64',
|
||
blocker: '',
|
||
needs: { node: true, tmux: true, git: false },
|
||
clis: [cli('claude', 'Claude Code'), cli('codex', 'Codex'), cli('deepseek', 'DeepSeek', { installable: false })],
|
||
...over,
|
||
});
|
||
|
||
const NOT_INSTALLED = {
|
||
url: 'http://127.0.0.1:3000/',
|
||
state: 'notInstalled',
|
||
installed: false,
|
||
installComplete: false,
|
||
installRunning: false,
|
||
installInApp: false,
|
||
found: [],
|
||
outcome: { kind: 'refused' },
|
||
};
|
||
|
||
let dom: JSDOM | null = null;
|
||
|
||
/** `first`: what the app says to a page that just loaded, with no look at this Mac yet. */
|
||
function open(first?: Record<string, unknown>) {
|
||
const sent: Sent[] = [];
|
||
dom = new JSDOM(HTML, {
|
||
url: 'codemanapp://localhost/index.html',
|
||
runScripts: 'dangerously',
|
||
beforeParse(window) {
|
||
(window as unknown as { ipc: unknown }).ipc = { postMessage: (m: string) => sent.push(JSON.parse(m)) };
|
||
},
|
||
});
|
||
const window = dom.window as unknown as Window & {
|
||
__codemanDesktop: { onNative(m: unknown): void };
|
||
};
|
||
const doc = window.document;
|
||
const $ = (id: string) => doc.getElementById(id) as HTMLElement;
|
||
const input = (id: string) => $(id) as HTMLInputElement;
|
||
const native = (m: unknown) => window.__codemanDesktop.onNative(m);
|
||
const click = (id: string) => $(id).click();
|
||
const visible = (id: string) => {
|
||
const el = $(id);
|
||
if (el.hidden) return false;
|
||
for (let p = el.parentElement; p; p = p.parentElement) if (p.hidden) return false;
|
||
return true;
|
||
};
|
||
const rail = () =>
|
||
Array.from(doc.querySelectorAll('#wiz-rail li')).map((li) => [li.querySelector('span')?.textContent, li.className]);
|
||
const sentOf = (cmd: string) => sent.filter((m) => m.cmd === cmd);
|
||
native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0', ...first });
|
||
if (!first) native({ type: 'localStatus', status: NOT_INSTALLED });
|
||
return { sent, sentOf, doc, $, input, native, click, visible, rail };
|
||
}
|
||
|
||
/** Open the wizard and answer its first question with `p`. */
|
||
function toPlan(p = plan()) {
|
||
const t = open();
|
||
t.click('local-install');
|
||
t.native({ type: 'installPlan', plan: p });
|
||
return t;
|
||
}
|
||
|
||
afterEach(() => {
|
||
dom?.window.close();
|
||
dom = null;
|
||
});
|
||
|
||
describe('starting the graphical install', () => {
|
||
it('asks the app for the installer instead of opening Terminal', () => {
|
||
const t = open();
|
||
t.click('local-install');
|
||
expect(t.sentOf('installPrepare')).toHaveLength(1);
|
||
expect(t.sentOf('installLocal')).toHaveLength(0);
|
||
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
|
||
expect(t.visible('wizard')).toBe(true);
|
||
});
|
||
|
||
it('hands the whole card to the install', () => {
|
||
const t = open();
|
||
t.click('local-install');
|
||
expect(t.doc.querySelector('.card')?.classList.contains('wizard-on')).toBe(true);
|
||
});
|
||
|
||
it('goes back to the tabs from any question', () => {
|
||
const t = toPlan();
|
||
t.click('wiz-back');
|
||
expect(t.visible('wizard')).toBe(false);
|
||
expect(t.doc.querySelector('.card')?.classList.contains('wizard-on')).toBe(false);
|
||
});
|
||
|
||
it('offers no way out while preparing, so two prepares never share the folder', () => {
|
||
const t = open();
|
||
t.click('local-install');
|
||
expect(t.visible('wiz-back')).toBe(false);
|
||
expect(t.visible('wiz-next')).toBe(false);
|
||
});
|
||
|
||
it('explains a failed download and offers Try again and Terminal', () => {
|
||
const t = open();
|
||
t.click('local-install');
|
||
t.native({ type: 'installPrepareFailed', message: 'Could not download the installer.' });
|
||
expect(t.$('wiz-message').textContent).toBe('Could not download the installer.');
|
||
expect(t.visible('wiz-terminal')).toBe(true);
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installPrepare')).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
describe('choosing a coding agent', () => {
|
||
it('asks when this Mac has none, listing what the installer can install, first one chosen', () => {
|
||
const t = toPlan();
|
||
expect(t.$('wiz-title').textContent).toBe('Choose a coding agent');
|
||
const radios = Array.from(t.doc.querySelectorAll<HTMLInputElement>('#wiz-agents input'));
|
||
expect(radios.map((r) => r.value)).toEqual(['claude', 'codex']);
|
||
expect(radios.map((r) => r.checked)).toEqual([true, false]);
|
||
});
|
||
|
||
it('does not ask when one is already there, and names it instead', () => {
|
||
const t = toPlan(
|
||
plan({ clis: [cli('claude', 'Claude Code', { installed: true, installable: false }), cli('codex', 'Codex')] })
|
||
);
|
||
expect(t.$('wiz-title').textContent).toBe('Who can reach Codeman?');
|
||
expect(t.$('wiz-summary').textContent).toContain('Uses Claude Code, already on this Mac');
|
||
t.click('wiz-back');
|
||
expect(t.visible('wizard')).toBe(false);
|
||
});
|
||
|
||
it('sends no agent then, so the installer never adds one', () => {
|
||
const t = toPlan(
|
||
plan({ clis: [cli('claude', 'Claude Code', { installed: true, installable: false }), cli('codex', 'Codex')] })
|
||
);
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installStart')[0].aiCli).toBeUndefined();
|
||
});
|
||
|
||
it('skips the question when nothing can be installed', () => {
|
||
const t = toPlan(plan({ clis: [cli('deepseek', 'DeepSeek', { installable: false })] }));
|
||
expect(t.$('wiz-title').textContent).toBe('Who can reach Codeman?');
|
||
expect(t.$('wiz-summary').textContent).toContain('No coding agent yet');
|
||
});
|
||
|
||
it('installs the one that was picked', () => {
|
||
const t = toPlan();
|
||
const codex = t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!;
|
||
codex.click();
|
||
expect(t.$('wiz-agent-note').textContent).toContain('You sign in to Codex');
|
||
t.click('wiz-next');
|
||
expect(t.$('wiz-summary').textContent).toContain('Installs Codex');
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart', aiCli: 'codex' }]);
|
||
});
|
||
|
||
it('Skip for now installs none, and says what that means', () => {
|
||
const t = toPlan();
|
||
t.click('wiz-skip');
|
||
expect(t.$('wiz-summary').textContent).toContain('No coding agent yet');
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart' }]);
|
||
});
|
||
|
||
it('Back from the next question returns to the choice, and keeps it', () => {
|
||
const t = toPlan();
|
||
t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!.click();
|
||
t.click('wiz-next');
|
||
t.click('wiz-back');
|
||
expect(t.$('wiz-title').textContent).toBe('Choose a coding agent');
|
||
expect(t.doc.querySelector<HTMLInputElement>('#wiz-agents input:checked')?.value).toBe('codex');
|
||
});
|
||
|
||
it('opens an agent docs link through the app, and only https', () => {
|
||
const t = toPlan();
|
||
t.doc.querySelector<HTMLAnchorElement>('#wiz-agent-note a')!.click();
|
||
expect(t.sentOf('openExternal')).toEqual([{ cmd: 'openExternal', url: 'https://example.org/claude' }]);
|
||
const u = toPlan(plan({ clis: [cli('x', 'X', { docs: 'javascript:alert(1)' })] }));
|
||
expect(u.doc.querySelector('#wiz-agent-note a')).toBeNull();
|
||
});
|
||
|
||
it('shows a label from the installer as text, never as markup', () => {
|
||
const t = toPlan(plan({ clis: [cli('x', '<img src=x onerror=alert(1)>')] }));
|
||
expect(t.doc.querySelector('#wiz-agents img')).toBeNull();
|
||
expect(t.$('wiz-agents').textContent).toContain('<img');
|
||
});
|
||
});
|
||
|
||
describe('choosing who can reach Codeman', () => {
|
||
const toReach = () => {
|
||
const t = toPlan();
|
||
t.click('wiz-next');
|
||
return t;
|
||
};
|
||
const network = (t: ReturnType<typeof open>) =>
|
||
t.doc.querySelector<HTMLInputElement>('input[name=reach][value=network]')!.click();
|
||
|
||
it('is this Mac alone unless changed, with no password', () => {
|
||
const t = toReach();
|
||
expect(t.visible('wiz-password')).toBe(false);
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installStart')[0].networkPassword).toBeUndefined();
|
||
});
|
||
|
||
it('refuses the network without a password', () => {
|
||
const t = toReach();
|
||
network(t);
|
||
expect(t.visible('wiz-password')).toBe(true);
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installStart')).toHaveLength(0);
|
||
expect(t.$('wiz-message').textContent).toContain('Set a password');
|
||
});
|
||
|
||
it('refuses two different passwords', () => {
|
||
const t = toReach();
|
||
network(t);
|
||
t.input('wiz-pw').value = 'one';
|
||
t.input('wiz-pw2').value = 'two';
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installStart')).toHaveLength(0);
|
||
expect(t.$('wiz-message').textContent).toContain('different');
|
||
});
|
||
|
||
it('sends the password once and clears the fields', () => {
|
||
const t = toReach();
|
||
network(t);
|
||
t.input('wiz-pw').value = 's3cret';
|
||
t.input('wiz-pw2').value = 's3cret';
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart', networkPassword: 's3cret', aiCli: 'claude' }]);
|
||
expect(t.input('wiz-pw').value).toBe('');
|
||
expect(t.input('wiz-pw2').value).toBe('');
|
||
});
|
||
|
||
it('never sends a password a person typed before going back to this Mac', () => {
|
||
const t = toReach();
|
||
network(t);
|
||
t.input('wiz-pw').value = 's3cret';
|
||
t.input('wiz-pw2').value = 's3cret';
|
||
t.doc.querySelector<HTMLInputElement>('input[name=reach][value=local]')!.click();
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installStart')[0].networkPassword).toBeUndefined();
|
||
});
|
||
|
||
it('keeps the Terminal route, carrying the choice', () => {
|
||
const t = toReach();
|
||
network(t);
|
||
t.click('wiz-terminal');
|
||
expect(t.sentOf('installLocal')).toEqual([{ cmd: 'installLocal', network: true }]);
|
||
expect(t.visible('wizard')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('when the no-admin route cannot finish here', () => {
|
||
it('says why and sends the person to Terminal', () => {
|
||
const t = toPlan(plan({ blocker: 'The tmux project’s build needs macOS 15 or newer.' }));
|
||
expect(t.$('wiz-message').textContent).toContain('macOS 15');
|
||
expect(t.$('wiz-next').textContent).toBe('Install in Terminal');
|
||
expect(t.visible('wiz-agent-step')).toBe(false);
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installLocal')).toHaveLength(1);
|
||
expect(t.sentOf('installStart')).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('following the install', () => {
|
||
const toRun = (p = plan()) => {
|
||
const t = toPlan(p);
|
||
t.click('wiz-next');
|
||
t.click('wiz-next');
|
||
t.native({ type: 'installRunning', port: 3001 });
|
||
return t;
|
||
};
|
||
|
||
it('draws only the phases this install has', () => {
|
||
const noDownloads = toRun(plan({ needs: { node: false, tmux: false, git: false } }));
|
||
expect(noDownloads.rail().map((r) => r[0])).toEqual([
|
||
'Installing Claude Code',
|
||
'Downloading Codeman',
|
||
'Installing packages',
|
||
'Building Codeman',
|
||
'Starting Codeman',
|
||
]);
|
||
const tmuxOnly = toRun(plan({ needs: { node: false, tmux: true, git: false } }));
|
||
expect(tmuxOnly.rail()[0][0]).toBe('Getting tmux');
|
||
});
|
||
|
||
it('moves the rail along as the installer reports, and ignores a phase it does not know', () => {
|
||
const t = toRun();
|
||
t.native({ type: 'installUpdate', phase: 'runtime', log: [] });
|
||
t.native({ type: 'installUpdate', phase: 'packages', log: ['==> Installing dependencies...'] });
|
||
t.native({ type: 'installUpdate', phase: 'somethingnew', log: [] });
|
||
expect(t.rail()).toEqual([
|
||
['Getting Node.js and tmux', 'done'],
|
||
['Installing Claude Code', 'done'],
|
||
['Downloading Codeman', 'done'],
|
||
['Installing packages', 'now'],
|
||
['Building Codeman', ''],
|
||
['Starting Codeman', ''],
|
||
]);
|
||
expect(t.$('wiz-log').textContent).toContain('Installing dependencies');
|
||
expect(t.$('wiz-clock')).not.toBeNull();
|
||
});
|
||
|
||
it('asks the app to cancel, once', () => {
|
||
const t = toRun();
|
||
t.click('wiz-cancel');
|
||
t.click('wiz-cancel');
|
||
expect(t.sentOf('installCancel')).toHaveLength(1);
|
||
});
|
||
|
||
it('a cancelled run goes back to the tabs and looks again', () => {
|
||
const t = toRun();
|
||
t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] });
|
||
expect(t.visible('wizard')).toBe(false);
|
||
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('a failed run shows the cause on its phase, opens the output and offers Try again', () => {
|
||
const t = toRun();
|
||
t.native({ type: 'installUpdate', phase: 'build', log: [] });
|
||
t.native({
|
||
type: 'installFinished',
|
||
ok: false,
|
||
cancelled: false,
|
||
error: 'Building Codeman failed (exit 1).',
|
||
log: ['npm error'],
|
||
});
|
||
expect(t.$('wiz-title').textContent).toBe('The install stopped');
|
||
expect(t.$('wiz-message').textContent).toBe('Building Codeman failed (exit 1).');
|
||
expect(t.rail().find((r) => r[1] === 'failed')?.[0]).toBe('Building Codeman');
|
||
expect((t.$('wiz-details') as HTMLDetailsElement).open).toBe(true);
|
||
expect(t.$('wiz-log').textContent).toContain('npm error');
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('installPrepare')).toHaveLength(2);
|
||
});
|
||
|
||
it('Try again keeps what was chosen', () => {
|
||
const t = toPlan();
|
||
t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!.click();
|
||
t.click('wiz-next');
|
||
t.click('wiz-next');
|
||
t.native({ type: 'installFinished', ok: false, cancelled: false, error: 'x', log: [] });
|
||
t.click('wiz-next');
|
||
t.native({ type: 'installPlan', plan: plan() });
|
||
expect(t.doc.querySelector<HTMLInputElement>('#wiz-agents input:checked')?.value).toBe('codex');
|
||
});
|
||
|
||
it('a run with no error text still says something', () => {
|
||
const t = toRun();
|
||
t.native({ type: 'installFinished', ok: false, cancelled: false, error: null, log: [] });
|
||
expect(t.$('wiz-message').textContent).toContain('without saying why');
|
||
});
|
||
|
||
it('a start the app refused is shown as a failure', () => {
|
||
const t = toPlan();
|
||
t.click('wiz-next');
|
||
t.click('wiz-next');
|
||
t.native({ type: 'installStartFailed', message: 'Codeman is already being installed.' });
|
||
expect(t.$('wiz-message').textContent).toBe('Codeman is already being installed.');
|
||
});
|
||
|
||
it('a finished run opens Codeman at its own port, not at whatever answers on 3000', () => {
|
||
const t = toRun();
|
||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||
expect(t.$('wiz-title').textContent).toBe('Starting Codeman');
|
||
expect(t.rail().every((r) => r[1] === 'done')).toBe(true);
|
||
expect(t.sentOf('localStatus').at(-1)?.quick).toBe(true);
|
||
|
||
const running = { ...NOT_INSTALLED, state: 'running', version: '1.48.0', installed: true };
|
||
t.native({ type: 'localStatus', status: { ...running, url: 'http://127.0.0.1:3000/' } });
|
||
expect(t.sentOf('connect')).toHaveLength(0);
|
||
t.native({ type: 'localStatus', status: { ...running, url: 'http://127.0.0.1:3001/' } });
|
||
expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: 'http://127.0.0.1:3001/' })]);
|
||
expect(t.visible('wizard')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('an install that was already running', () => {
|
||
it('is picked up from the app, with no question asked', () => {
|
||
const t = open();
|
||
t.native({
|
||
type: 'localStatus',
|
||
status: { ...NOT_INSTALLED, installRunning: true, installInApp: true, installPort: 3002 },
|
||
});
|
||
expect(t.sentOf('installAttach')).toHaveLength(1);
|
||
expect(t.$('wiz-title').textContent).toBe('Installing Codeman');
|
||
expect(t.visible('wiz-cancel')).toBe(true);
|
||
});
|
||
|
||
it('asks only once, however many looks arrive', () => {
|
||
const t = open();
|
||
const status = { ...NOT_INSTALLED, installRunning: true, installInApp: true };
|
||
t.native({ type: 'localStatus', status });
|
||
t.native({ type: 'localStatus', status });
|
||
expect(t.sentOf('installAttach')).toHaveLength(1);
|
||
});
|
||
|
||
it('draws what the installer reports, since it has no plan', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: true } });
|
||
t.native({ type: 'installUpdate', phase: 'agent', log: [] });
|
||
t.native({ type: 'installUpdate', phase: 'build', log: [] });
|
||
expect(t.rail().map((r) => r[0])).toContain('Installing a coding agent');
|
||
expect(t.rail().find((r) => r[1] === 'now')?.[0]).toBe('Building Codeman');
|
||
});
|
||
|
||
it('never takes a graphical install for one in Terminal', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: true } });
|
||
expect(t.$('local-title').textContent).not.toContain('Terminal');
|
||
expect(t.visible('local-stop')).toBe(false);
|
||
});
|
||
|
||
it('an install still running in Terminal keeps the old wait', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: false } });
|
||
expect(t.sentOf('installAttach')).toHaveLength(0);
|
||
expect(t.$('local-title').textContent).toContain('Terminal');
|
||
});
|
||
});
|
||
|
||
describe('adding a coding agent to a Mac that has Codeman', () => {
|
||
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
|
||
const STOPPED = { ...NOT_INSTALLED, state: 'stopped', installed: true, installComplete: true };
|
||
const AGENTS = {
|
||
clis: [
|
||
cli('claude', 'Claude Code', { installed: true, installable: false }),
|
||
cli('codex', 'Codex'),
|
||
cli('copilot', 'GitHub Copilot'),
|
||
cli('deepseek', 'DeepSeek', { installable: false }),
|
||
],
|
||
};
|
||
|
||
function chooser(status: Record<string, unknown> = RUNNING, over: Record<string, unknown> = AGENTS) {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status });
|
||
t.click('local-agents');
|
||
t.native({ type: 'installPlan', plan: plan({ needs: { node: false, tmux: false, git: false }, ...over }) });
|
||
return t;
|
||
}
|
||
|
||
function installing(status: Record<string, unknown> = RUNNING) {
|
||
const t = chooser(status);
|
||
t.click('wiz-next');
|
||
t.native({ type: 'installUpdate', phase: 'agent', log: ['Installing Codex...'] });
|
||
return t;
|
||
}
|
||
|
||
it('is offered whenever Codeman is here, and never when it is not', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: NOT_INSTALLED });
|
||
expect(t.visible('local-agents')).toBe(false);
|
||
for (const status of [
|
||
RUNNING,
|
||
STOPPED,
|
||
{ ...RUNNING, state: 'needsLogin' },
|
||
{
|
||
...RUNNING,
|
||
state: 'runningElsewhere',
|
||
found: [{ url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false }],
|
||
},
|
||
]) {
|
||
t.native({ type: 'localStatus', status });
|
||
expect(t.visible('local-agents'), String(status.state)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('is not offered while an install in Terminal still runs', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: { ...STOPPED, installRunning: true } });
|
||
expect(t.visible('local-agents')).toBe(false);
|
||
});
|
||
|
||
it('asks the app for the agent route, never the install route', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: RUNNING });
|
||
t.click('local-agents');
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(1);
|
||
expect(t.sentOf('installPrepare')).toHaveLength(0);
|
||
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
|
||
expect(t.visible('wiz-back')).toBe(false); // A second prepare would clear the first one's folder.
|
||
});
|
||
|
||
it('lists what can be added first, what is here after it, and leaves out the rest', () => {
|
||
const t = chooser();
|
||
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
|
||
const rows = [...t.$('wiz-agents').querySelectorAll('label')];
|
||
expect(rows.map((r) => r.querySelector('b')?.textContent)).toEqual(['Codex', 'GitHub Copilot', 'Claude Code']);
|
||
const inputs = rows.map((r) => r.querySelector('input') as HTMLInputElement);
|
||
expect(inputs.map((i) => i.disabled)).toEqual([false, false, true]);
|
||
expect(rows[2].textContent).toContain('Installed');
|
||
expect(inputs[0].checked).toBe(true);
|
||
});
|
||
|
||
it('asks nothing about Codeman, whatever the installer says about installing it', () => {
|
||
const t = chooser(RUNNING, { ...AGENTS, blocker: 'macOS is too old for the tmux download.' });
|
||
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
|
||
expect(t.visible('wiz-reach-step')).toBe(false);
|
||
expect(t.visible('wiz-skip')).toBe(false);
|
||
expect(t.visible('wiz-terminal')).toBe(false);
|
||
});
|
||
|
||
it('sends the one agent chosen, and names it on the button', () => {
|
||
const t = chooser();
|
||
expect(t.$('wiz-next').textContent).toBe('Install Codex');
|
||
const copilot = t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement;
|
||
copilot.click();
|
||
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('agentStart')).toEqual([{ cmd: 'agentStart', aiCli: 'copilot' }]);
|
||
expect(t.sentOf('installStart')).toHaveLength(0);
|
||
expect(t.$('wiz-title').textContent).toBe('Installing GitHub Copilot');
|
||
});
|
||
|
||
it('draws one row, for that agent', () => {
|
||
const t = installing();
|
||
expect(t.rail()).toEqual([['Installing Codex', 'now']]);
|
||
expect(t.visible('wiz-cancel')).toBe(true);
|
||
});
|
||
|
||
it('ends on what happened, and opens Codeman only when asked', () => {
|
||
const t = installing();
|
||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
|
||
expect(t.$('wiz-lead').textContent).toContain('reload it, or give it a few minutes');
|
||
expect(t.rail()).toEqual([['Installing Codex', 'done']]);
|
||
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
|
||
expect(t.sentOf('connect')).toHaveLength(0);
|
||
t.native({ type: 'localStatus', status: RUNNING });
|
||
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
|
||
expect(t.$('wiz-next').textContent).toBe('Open Codeman');
|
||
expect(t.$('wiz-back').textContent).toBe('Done');
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: RUNNING.url })]);
|
||
expect(t.visible('wizard')).toBe(false);
|
||
});
|
||
|
||
it('has nothing to open when Codeman is stopped, and Done goes back to This Mac', () => {
|
||
const t = installing(STOPPED);
|
||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||
t.native({ type: 'localStatus', status: STOPPED });
|
||
expect(t.visible('wiz-next')).toBe(false);
|
||
t.click('wiz-back');
|
||
expect(t.visible('wizard')).toBe(false);
|
||
expect(t.visible('local-start')).toBe(true);
|
||
expect(t.visible('local-agents')).toBe(true);
|
||
});
|
||
|
||
it('offers another only when there is another, and asks the installer again', () => {
|
||
const t = installing();
|
||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||
expect(t.$('wiz-skip').textContent).toBe('Add another');
|
||
t.click('wiz-skip');
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(2);
|
||
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
|
||
|
||
const last = chooser(RUNNING, { clis: [cli('codex', 'Codex')] });
|
||
last.click('wiz-next');
|
||
last.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||
expect(last.visible('wiz-skip')).toBe(false);
|
||
});
|
||
|
||
it('says so when there is nothing left to add', () => {
|
||
const t = chooser(RUNNING, { clis: [cli('claude', 'Claude Code', { installed: true, installable: false })] });
|
||
expect(t.$('wiz-lead').textContent).toContain('already on this Mac');
|
||
expect(t.visible('wiz-next')).toBe(false);
|
||
expect(t.visible('wiz-back')).toBe(true);
|
||
t.click('wiz-back');
|
||
expect(t.visible('wizard')).toBe(false);
|
||
});
|
||
|
||
it('a failure shows the reason, and Try again asks again with the same choice', () => {
|
||
const t = chooser();
|
||
(t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement).click();
|
||
t.click('wiz-next');
|
||
t.native({
|
||
type: 'installFinished',
|
||
ok: false,
|
||
cancelled: false,
|
||
error: 'GitHub Copilot did not install.',
|
||
log: ['npm ERR! nope'],
|
||
});
|
||
expect(t.$('wiz-title').textContent).toBe('The install stopped');
|
||
expect(t.$('wiz-message').textContent).toBe('GitHub Copilot did not install.');
|
||
expect(t.$('wiz-log').textContent).toContain('npm ERR! nope');
|
||
expect(t.visible('wiz-terminal')).toBe(false);
|
||
expect(t.visible('wiz-back')).toBe(true);
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(2);
|
||
t.native({ type: 'installPlan', plan: plan(AGENTS) });
|
||
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
|
||
});
|
||
|
||
it('a list it could not get says so, with no Terminal route to Codeman', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: RUNNING });
|
||
t.click('local-agents');
|
||
t.native({ type: 'installPrepareFailed', message: 'No network.' });
|
||
expect(t.$('wiz-title').textContent).toBe('Could not get the list of coding agents');
|
||
expect(t.$('wiz-message').textContent).toBe('No network.');
|
||
expect(t.visible('wiz-terminal')).toBe(false);
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(2);
|
||
});
|
||
|
||
it('Cancel goes through the same command and leaves This Mac as it was', () => {
|
||
const t = installing();
|
||
t.click('wiz-cancel');
|
||
expect(t.sentOf('installCancel')).toHaveLength(1);
|
||
t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] });
|
||
expect(t.visible('wizard')).toBe(false);
|
||
expect(t.$('status-text').textContent).toContain('Stopped installing Codex.');
|
||
});
|
||
|
||
it('is picked up after the app was reopened, as an agent run and never as an install', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
|
||
expect(t.sentOf('agentAttach')).toHaveLength(1);
|
||
expect(t.sentOf('installAttach')).toHaveLength(0);
|
||
expect(t.$('wiz-title').textContent).toBe('Installing a coding agent');
|
||
t.native({ type: 'installUpdate', phase: 'agent', log: [] });
|
||
expect(t.rail()).toEqual([['Installing a coding agent', 'now']]);
|
||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||
expect(t.$('wiz-title').textContent).toBe('The coding agent is installed');
|
||
});
|
||
});
|
||
|
||
describe('the menu item that adds a coding agent', () => {
|
||
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
|
||
|
||
it('opens the chooser on a page that is up', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: RUNNING });
|
||
t.native({ type: 'addAgent' });
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(1);
|
||
});
|
||
|
||
it('waits for the first look when it left a Codeman page to get here', () => {
|
||
const t = open({ servers: [{ url: 'http://example.org:3000/' }], addAgent: true });
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(0);
|
||
t.native({ type: 'localStatus', status: RUNNING });
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(1);
|
||
expect(t.$('tab-local').getAttribute('aria-selected')).toBe('true');
|
||
});
|
||
|
||
it('leaves a Mac without Codeman on This Mac, with Install', () => {
|
||
const t = open({ addAgent: true });
|
||
t.native({ type: 'localStatus', status: NOT_INSTALLED });
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(0);
|
||
expect(t.visible('wizard')).toBe(false);
|
||
expect(t.visible('local-install')).toBe(true);
|
||
});
|
||
|
||
it('never replaces a run on screen', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
|
||
t.native({ type: 'addAgent' });
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('after Skip for now', () => {
|
||
function skipped() {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: NOT_INSTALLED });
|
||
t.click('local-install');
|
||
t.native({ type: 'installPlan', plan: plan() });
|
||
t.click('wiz-skip');
|
||
t.click('wiz-next');
|
||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||
t.native({
|
||
type: 'localStatus',
|
||
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
|
||
});
|
||
return t;
|
||
}
|
||
|
||
it('stops at Codeman being ready instead of opening it with nothing to run', () => {
|
||
const t = skipped();
|
||
expect(t.$('wiz-title').textContent).toBe('Codeman is ready');
|
||
expect(t.$('wiz-lead').textContent).toContain('no coding agent');
|
||
expect(t.sentOf('connect')).toHaveLength(0);
|
||
});
|
||
|
||
it('Open Codeman opens it anyway', () => {
|
||
const t = skipped();
|
||
t.click('wiz-next');
|
||
expect(t.sentOf('connect')).toHaveLength(1);
|
||
});
|
||
|
||
it('Add a coding agent goes to the chooser', () => {
|
||
const t = skipped();
|
||
expect(t.$('wiz-skip').textContent).toBe('Add a coding agent');
|
||
t.click('wiz-skip');
|
||
expect(t.sentOf('agentPrepare')).toHaveLength(1);
|
||
});
|
||
|
||
it('an install that got an agent still opens Codeman by itself', () => {
|
||
const t = open();
|
||
t.native({ type: 'localStatus', status: NOT_INSTALLED });
|
||
t.click('local-install');
|
||
t.native({ type: 'installPlan', plan: plan() });
|
||
t.click('wiz-next');
|
||
t.click('wiz-next');
|
||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||
t.native({
|
||
type: 'localStatus',
|
||
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
|
||
});
|
||
expect(t.sentOf('connect')).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('Codeman servers found on this Mac', () => {
|
||
type Found = { url: string; version: string | null; state: string; isDefault: boolean };
|
||
const found = (url: string, state = 'running', version: string | null = '1.49.0'): Found => ({
|
||
url,
|
||
version: state === 'running' ? version : null,
|
||
state,
|
||
isDefault: false,
|
||
});
|
||
const ELSEWHERE = {
|
||
...NOT_INSTALLED,
|
||
state: 'runningElsewhere',
|
||
installed: true,
|
||
installComplete: true,
|
||
found: [found('http://127.0.0.1:4000/'), found('http://192.168.1.5:5000/cm/', 'needsLogin')],
|
||
};
|
||
const RUNNING = {
|
||
...NOT_INSTALLED,
|
||
state: 'running',
|
||
version: '1.49.0',
|
||
installed: true,
|
||
installComplete: true,
|
||
outcome: { kind: 'ok', version: '1.49.0' },
|
||
found: [{ ...found('http://127.0.0.1:3000/'), isDefault: true }],
|
||
};
|
||
|
||
function at(status: Record<string, unknown>, first?: Record<string, unknown>) {
|
||
const t = open(first ?? { servers: [] });
|
||
t.native({ type: 'localStatus', status });
|
||
const rows = (list: string) =>
|
||
Array.from(t.$(list).querySelectorAll('.found')).map((row) => ({
|
||
address: row.querySelector('b')?.textContent,
|
||
note: row.querySelector('small')?.textContent ?? '',
|
||
button: row.querySelector('button'),
|
||
}));
|
||
return { ...t, rows };
|
||
}
|
||
|
||
it('recommends the server that runs at another address, a row each, with no trip to the other tab', () => {
|
||
const t = at(ELSEWHERE);
|
||
expect(t.$('local-title').textContent).toBe('Codeman is already running on this Mac.');
|
||
expect(t.$('local-detail').textContent).toBe('');
|
||
const rows = t.rows('local-found');
|
||
expect(rows.map((r) => [r.address, r.note])).toEqual([
|
||
['http://127.0.0.1:4000/', 'version 1.49.0'],
|
||
['http://192.168.1.5:5000/cm/', 'Asks for a login.'],
|
||
]);
|
||
expect(rows.map((r) => r.button?.textContent)).toEqual(['Connect', 'Connect']);
|
||
// The first row the app can open is the tab's main action.
|
||
expect(rows.map((r) => r.button?.className)).toEqual(['primary', 'secondary']);
|
||
expect(rows[0].button?.getAttribute('aria-label')).toBe('Connect to http://127.0.0.1:4000/');
|
||
expect(t.visible('local-found-note')).toBe(true);
|
||
expect(t.$('local-found-note').textContent).toBe('Starting another server here would take over its sessions.');
|
||
expect(t.$('pane-local').textContent).not.toContain('Another machine');
|
||
expect(t.visible('local-check')).toBe(true);
|
||
for (const id of ['local-open', 'local-start', 'local-install', 'local-also']) {
|
||
expect(t.visible(id), id).toBe(false);
|
||
}
|
||
expect(t.$('local-dot').className).toBe('dot ok');
|
||
// Finding a server connects to nothing by itself.
|
||
expect(t.sentOf('connect')).toHaveLength(0);
|
||
});
|
||
|
||
it('Connect is the connect of an address typed under Another machine', () => {
|
||
const t = at(ELSEWHERE);
|
||
t.rows('local-found')[1].button!.click();
|
||
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://192.168.1.5:5000/cm/', remember: true }]);
|
||
expect(t.input('url').value).toBe('http://192.168.1.5:5000/cm/');
|
||
expect(t.$('status-text').textContent).toContain('Connecting to http://192.168.1.5:5000/cm/');
|
||
// One request at a time, as everywhere on the page.
|
||
expect(t.rows('local-found').every((r) => r.button!.disabled)).toBe(true);
|
||
t.rows('local-found')[0].button!.click();
|
||
expect(t.sentOf('connect')).toHaveLength(1);
|
||
});
|
||
|
||
it('uses the login saved for that server, and never the one of the server shown before', () => {
|
||
const servers = [
|
||
{ url: 'https://box.ts.net/', username: 'other', hasSavedPassword: true },
|
||
{ url: 'http://127.0.0.1:4000/', username: 'kay', hasSavedPassword: true },
|
||
];
|
||
const t = at(ELSEWHERE, { servers });
|
||
t.click('tab-local');
|
||
t.rows('local-found')[0].button!.click();
|
||
expect(t.sentOf('connect')).toEqual([
|
||
{ cmd: 'connect', url: 'http://127.0.0.1:4000/', remember: true, username: 'kay' },
|
||
]);
|
||
const u = at(ELSEWHERE, { servers: servers.slice(0, 1) });
|
||
u.click('tab-local');
|
||
u.rows('local-found')[0].button!.click();
|
||
expect(u.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://127.0.0.1:4000/', remember: true }]);
|
||
});
|
||
|
||
it('a server that asks for a login gets the login fields, as any connect does', () => {
|
||
const t = at(ELSEWHERE);
|
||
t.rows('local-found')[1].button!.click();
|
||
t.native({
|
||
type: 'probeResult',
|
||
url: 'http://192.168.1.5:5000/cm/',
|
||
outcome: { kind: 'needsLogin' },
|
||
canStart: false,
|
||
});
|
||
expect(t.$('tab-remote').getAttribute('aria-selected')).toBe('true');
|
||
expect(t.visible('login')).toBe(true);
|
||
expect(t.input('url').value).toBe('http://192.168.1.5:5000/cm/');
|
||
});
|
||
|
||
it('says why instead of offering Connect for a server the app cannot open', () => {
|
||
const t = at({
|
||
...ELSEWHERE,
|
||
found: [
|
||
found('https://127.0.0.1:4000/', 'certificate'),
|
||
found('https://192.168.1.5:4100/', 'certificate'),
|
||
found('http://[::1]:5000/', 'unreachable'),
|
||
found('http://127.0.0.1:6000/'),
|
||
],
|
||
});
|
||
const rows = t.rows('local-found');
|
||
expect(rows.map((r) => !!r.button)).toEqual([false, false, false, true]);
|
||
// The wording of a stopped Codeman that is set up with HTTPS.
|
||
const https =
|
||
'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
|
||
expect(rows.slice(0, 2).map((r) => r.note)).toEqual([https, https]);
|
||
expect(rows[2].note).toBe('It does not answer at this address.');
|
||
const stopped = at({ ...NOT_INSTALLED, state: 'stopped', installed: true, url: 'https://127.0.0.1:3000/' });
|
||
expect(stopped.$('local-detail').textContent).toBe(https);
|
||
// The first one it can open is still the main action.
|
||
expect(rows[3].button?.className).toBe('primary');
|
||
expect(t.$('local-dot').className).toBe('dot ok');
|
||
});
|
||
|
||
it('with no server it can open, offers no Connect and keeps Check again', () => {
|
||
const t = at({ ...ELSEWHERE, found: [found('https://127.0.0.1:4000/', 'certificate')] });
|
||
expect(t.$('pane-local').querySelectorAll('.found button')).toHaveLength(0);
|
||
expect(t.$('local-dot').className).toBe('dot warn');
|
||
expect(t.visible('local-found-note')).toBe(true);
|
||
expect(t.visible('local-check')).toBe(true);
|
||
t.click('local-check');
|
||
expect(t.sentOf('localStatus').at(-1)).toEqual({ cmd: 'localStatus', quick: false, deep: true });
|
||
});
|
||
|
||
it('a server process with no address to try still says what to do', () => {
|
||
const t = at({ ...ELSEWHERE, found: [] });
|
||
expect(t.$('local-title').textContent).toBe('Codeman is already running on this Mac.');
|
||
expect(t.$('local-detail').textContent).toContain('could not find the address it listens at');
|
||
expect(t.$('local-detail').textContent).toContain('take over its sessions');
|
||
expect(t.visible('local-found')).toBe(false);
|
||
expect(t.visible('local-found-note')).toBe(false);
|
||
// A status from before the list existed reads the same.
|
||
const old = at({ ...ELSEWHERE, found: undefined });
|
||
expect(old.$('local-detail').textContent).toContain('could not find the address it listens at');
|
||
});
|
||
|
||
it('keeps Open for the Codeman that answers, and lists the others under it', () => {
|
||
const t = at({
|
||
...RUNNING,
|
||
found: [
|
||
...RUNNING.found,
|
||
found('http://127.0.0.1:5000/', 'running', '1.50.0-beta'),
|
||
found('https://127.0.0.1:6000/', 'certificate'),
|
||
],
|
||
});
|
||
expect(t.$('local-title').textContent).toBe('Codeman is running on this Mac.');
|
||
expect(t.$('local-detail').textContent).toBe('http://127.0.0.1:3000/ · version 1.49.0');
|
||
expect(t.visible('local-open')).toBe(true);
|
||
expect(t.visible('local-also')).toBe(true);
|
||
expect(t.$('local-also-head').textContent).toBe('Also running on this Mac');
|
||
const rows = t.rows('local-also-list');
|
||
// Not the one the first line is about.
|
||
expect(rows.map((r) => [r.address, r.note.slice(0, 19)])).toEqual([
|
||
['http://127.0.0.1:5000/', 'version 1.50.0-beta'],
|
||
['https://127.0.0.1:6000/', 'It is set up with H'],
|
||
]);
|
||
// Open stays the main action: a row's Connect is never the primary one here.
|
||
expect(rows.map((r) => r.button?.className)).toEqual(['secondary', undefined]);
|
||
expect(t.visible('local-found')).toBe(false);
|
||
expect(t.visible('local-found-note')).toBe(false);
|
||
rows[0].button!.click();
|
||
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://127.0.0.1:5000/', remember: true }]);
|
||
});
|
||
|
||
it('lists them beside a Codeman that asks for a login, and beside something else on its port', () => {
|
||
const other = found('http://127.0.0.1:5000/');
|
||
const login = at({ ...RUNNING, state: 'needsLogin', outcome: { kind: 'needsLogin' }, found: [other] });
|
||
expect(login.visible('local-open')).toBe(true);
|
||
expect(login.rows('local-also-list').map((r) => r.address)).toEqual(['http://127.0.0.1:5000/']);
|
||
|
||
const taken = { ...RUNNING, state: 'other', version: null, outcome: { kind: 'notCodeman', status: 404 } };
|
||
const t = at({ ...taken, found: [other] });
|
||
expect(t.$('local-title').textContent).toBe('Something else answers at http://127.0.0.1:3000/');
|
||
expect(t.$('local-detail').textContent).toContain('or connect to the Codeman listed below.');
|
||
expect(t.rows('local-also-list').map((r) => r.button?.textContent)).toEqual(['Connect']);
|
||
// With nothing to connect to, the old pointer stays.
|
||
const none = at({ ...taken, found: [] });
|
||
expect(none.$('local-detail').textContent).toContain('under Another machine');
|
||
expect(none.visible('local-also')).toBe(false);
|
||
});
|
||
|
||
it('shows no list when this Mac runs only the Codeman the tab is about', () => {
|
||
const t = at(RUNNING);
|
||
expect(t.visible('local-also')).toBe(false);
|
||
expect(t.$('pane-local').querySelectorAll('.found')).toHaveLength(0);
|
||
// The rows of an earlier look go when the next one finds none.
|
||
t.native({ type: 'localStatus', status: ELSEWHERE });
|
||
expect(t.rows('local-found')).toHaveLength(2);
|
||
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, state: 'stopped', installed: true } });
|
||
expect(t.$('pane-local').querySelectorAll('.found')).toHaveLength(0);
|
||
expect(t.visible('local-found')).toBe(false);
|
||
expect(t.visible('local-found-note')).toBe(false);
|
||
});
|
||
|
||
it('shows what the app found as text, never as markup', () => {
|
||
const hostile = '"><img src=x onerror=alert(1)><script>alert(2)</script>';
|
||
const t = at({
|
||
...ELSEWHERE,
|
||
found: [{ url: hostile, version: '<b onclick=alert(3)>9</b>', state: 'running', isDefault: false }],
|
||
});
|
||
const rows = t.rows('local-found');
|
||
expect(t.$('pane-local').querySelector('img, script, small b')).toBeNull();
|
||
expect(rows[0].address).toBe(hostile);
|
||
expect(rows[0].note).toBe('version <b onclick=alert(3)>9</b>');
|
||
expect(rows[0].button?.title).toBe('Connect to ' + hostile);
|
||
// The app gets it as it is, and refuses what is not an address (policy::normalize_server_url).
|
||
rows[0].button!.click();
|
||
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: hostile, remember: true }]);
|
||
});
|
||
|
||
it('holds a found address to the length the app accepts (MAX_URL_LEN in policy.rs)', () => {
|
||
const long = 'http://127.0.0.1:4000/' + 'a'.repeat(4096);
|
||
const t = at({ ...ELSEWHERE, found: [found(long)] });
|
||
t.rows('local-found')[0].button!.click();
|
||
expect(t.sentOf('connect')).toHaveLength(0);
|
||
expect(t.$('status-text').textContent).toContain('too long');
|
||
});
|
||
|
||
it('looks again when the server it connected to refuses, and opens nothing by itself', () => {
|
||
const t = at(ELSEWHERE);
|
||
const looks = t.sentOf('localStatus').length;
|
||
t.rows('local-found')[0].button!.click();
|
||
t.native({ type: 'probeResult', url: 'http://127.0.0.1:4000/', outcome: { kind: 'refused' }, canStart: true });
|
||
expect(t.$('tab-local').getAttribute('aria-selected')).toBe('true');
|
||
expect(t.$('status-text').textContent).toContain('Nothing is answering at this address.');
|
||
expect(t.sentOf('localStatus')).toHaveLength(looks + 1);
|
||
// A server that then turns out to be starting is shown, not opened: the
|
||
// one that comes up may not be the one this row was about.
|
||
t.native({ type: 'localStatus', status: { ...ELSEWHERE, state: 'starting', found: [] } });
|
||
t.native({ type: 'localStatus', status: RUNNING });
|
||
expect(t.sentOf('connect')).toHaveLength(1);
|
||
expect(t.visible('local-open')).toBe(true);
|
||
});
|
||
|
||
it('still opens the Codeman of the first line once it is up, after Open was refused', () => {
|
||
const t = at(RUNNING);
|
||
t.click('local-open');
|
||
t.native({ type: 'probeResult', url: RUNNING.url, outcome: { kind: 'refused' }, canStart: true });
|
||
t.native({ type: 'localStatus', status: { ...RUNNING, state: 'starting', found: [] } });
|
||
t.native({ type: 'localStatus', status: RUNNING });
|
||
expect(t.sentOf('connect').map((m) => m.url)).toEqual([RUNNING.url, RUNNING.url]);
|
||
});
|
||
});
|