Files
Codeman/test/desktop-connect-install.test.ts
T
Codeman maintainer dd07126dd8 feat(desktop): add a coding agent once Codeman is installed
The agent chooser only appeared during the install, and only on a Mac with
no agent. After "Skip for now", or with Codeman already installed, there was
no screen for it.

The same chooser now works after the install: "Add a coding agent" on the
This Mac tab, "Add a Coding Agent to This Mac..." in the app menu (it also
works from a Codeman page), and after a Skip the app says Codeman has no
agent yet before it opens Codeman.

`install.sh agent <id>` installs one agent and nothing else. It checks that
THAT agent landed: install_offered_cli is fatal only when no CLI is left, so
with another one present a failed install would have reported success.

The app runs it as a run of its own (`gui_install::Run::Agent`), in its own
temporary folder, so `ps` never reads it as "Codeman is being installed". It
may run next to a live server, which an install must refuse, and stops only
for another run. `--no-admin` goes to both `preflight` and `agent`: it decides
what counts as installable, so the two must agree.

CI: the bash 3.2 preflight check never tested anything. Its child run
inherited CODEMAN_INSTALL_SH_LIB and stopped at the sourcing guard, and a
false [[ ]] does not trip set -e on bash 3.2. Both are fixed for that check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:23:56 +01:00

738 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview The graphical install's screens in the Codeman Desktop connect page.
*
* 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`) and src/gui_install.rs, which do not build on Linux
* CI, so the spelling of every message the page sends 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,
elsewhere: [],
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', elsewhere: ['127.0.0.1:4000'] },
]) {
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('within a few minutes');
expect(t.$('wiz-lead').textContent).toContain('reload');
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);
});
});