From beb8952841a7b97f8d2d6925e04bf215a3f0e22d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 22 Sep 2026 10:41:59 -0400 Subject: [PATCH] fix(beehive): replace routine status actions with automatic checks Signed-off-by: Logan Johnson --- beehive/src/host-panel.ts | 9 ++- beehive/src/owner-panel.ts | 14 ++-- beehive/src/owner-service.ts | 105 +++++++++++++++------------ beehive/test/owner-service.test.ts | 31 ++++++++ beehive/test/shell-host.test.ts | 10 +-- beehive/test/shell-owner-ipc.test.ts | 28 +++++++ beehive/test/shell-owner.test.ts | 6 +- 7 files changed, 140 insertions(+), 63 deletions(-) diff --git a/beehive/src/host-panel.ts b/beehive/src/host-panel.ts index 4467fc7a6e..009a24bb12 100644 --- a/beehive/src/host-panel.ts +++ b/beehive/src/host-panel.ts @@ -31,7 +31,8 @@ export class HostPanel { get modal() { return !!this.confirmation; } private commands() { const host = this.snapshot.host; - return [...(host?.state === 'running' ? ['Stop Host'] : host?.state === 'stopped' ? [...(host.resetPending ? [] : ['Start Host']), 'Reset Host'] : []), 'Refresh status']; + if (!host || host.state === 'unknown') return this.snapshot.hostPhase === 'error' || host?.state === 'unknown' ? ['Retry'] : []; + return [...(host.state === 'running' ? ['Stop Host'] : [...(host.resetPending ? [] : ['Start Host']), 'Reset Host']), ...(this.snapshot.hostPhase === 'error' ? ['Retry'] : [])]; } private refresh() { if (this.shown && !this.confirmation && this.snapshot.hostPhase !== 'busy' && this.snapshot.hostPhase !== 'error') void this.client.request({ action: 'host-status' }); } key(key: KeyEvent) { @@ -50,8 +51,8 @@ export class HostPanel { private async run() { if (!this.shown || this.confirmation || this.snapshot.hostPhase === 'busy') return; const command = this.commands()[this.index], host = this.snapshot.host, generation = this.generation; - if (command === 'Refresh status') { await this.client.request({ action: 'host-status' }); return; } - if (!host) return; + if (command === 'Retry') { await this.client.request({ action: 'host-status' }); return; } + if (!host || !command) return; if (command === 'Stop Host' || command === 'Reset Host') { const dialog = new HostConfirm(this.renderer, command === 'Reset Host'); this.confirmation = dialog; const confirmed = await dialog.done; if (this.confirmation === dialog) this.confirmation = undefined; @@ -74,7 +75,7 @@ export class HostPanel { this.facts.top = 1; this.facts.left = 2 + labelWidth; this.facts.width = width - labelWidth; this.facts.height = 5; this.facts.content = host ? [host.resetPending ? 'RESET INCOMPLETE' : host.state.toUpperCase(), host.name, host.owner, host.relay, host.agents === undefined ? 'Unknown' : String(host.agents)].map(truncate).join('\n') : `${this.snapshot.hostPhase === 'error' ? 'UNKNOWN' : 'Checking…'}\n—\n—\n—\nUnknown`; this.heading.top = 7; this.heading.height = 1; this.heading.width = width; this.heading.content = 'AVAILABLE ACTIONS ' + '─'.repeat(Math.max(1, width - 18)); - const commands = this.commands(); this.index = Math.max(0, Math.min(this.index, commands.length - 1)); + const commands = this.commands(); this.heading.visible = commands.length > 0; this.index = Math.max(0, Math.min(this.index, commands.length - 1)); this.actions.forEach((action, i) => { action.top = this.actionTop + i; action.width = width; action.height = 1; action.visible = i < commands.length; action.content = t`${fg(palette.focus)(this.state.focus === 'detail' && this.index === i ? '›' : ' ')} ${commands[i] ?? ''}`; action.fg = commands[i] === 'Reset Host' ? palette.failure : this.state.focus === 'detail' && this.index === i ? palette.focus : palette.text; }); this.message.top = this.actionTop + commands.length; this.message.width = width; this.message.height = Math.max(1, height - Number(this.message.top) - 1); this.message.content = this.snapshot.hostMessage || 'Host runs independently of the manager.'; this.message.fg = this.snapshot.hostPhase === 'error' ? palette.failure : palette.muted; diff --git a/beehive/src/owner-panel.ts b/beehive/src/owner-panel.ts index 575df84cb3..34cad4e0b8 100644 --- a/beehive/src/owner-panel.ts +++ b/beehive/src/owner-panel.ts @@ -25,6 +25,7 @@ export class OwnerPanel { private shown = false; private generation = 0; private wasOwner = false; + private timer?: ReturnType; constructor(private renderer: CliRenderer, pane: BoxRenderable, private state: ShellState, private client: OwnerClient, private repaint: () => void) { this.snapshot = client.snapshot(); this.box = new BoxRenderable(renderer, { width: '100%', height: '100%', backgroundColor: palette.surface }); pane.add(this.box); @@ -41,7 +42,7 @@ export class OwnerPanel { if (!this.state.ownerActive) return this.snapshot.signedIn ? [] : ['Sign in']; if (this.snapshot.signedIn) return ['Sign out']; if (!this.snapshot.relay && !this.relay) return ['Choose a relay', 'Use Buzz Desktop identity', 'Provide owner nsec']; - return ['Use Buzz Desktop identity', 'Provide owner nsec', ...(this.snapshot.relay ? [] : ['Change relay']), 'Check Desktop identity']; + return ['Use Buzz Desktop identity', 'Provide owner nsec', ...(this.snapshot.relay ? [] : ['Change relay'])]; } private disabled(command: string) { return command === 'Use Buzz Desktop identity' && (this.snapshot.desktop !== 'available' || !(this.snapshot.relay || this.relay)) || command === 'Provide owner nsec' && !(this.snapshot.relay || this.relay); } private selectionNote(command: string) { @@ -93,7 +94,6 @@ export class OwnerPanel { const generation = this.generation; if (command === 'Sign in') { this.openOwner(); return; } if (command === 'Sign out') { this.requestedSection = undefined; this.prior = undefined; this.index = 0; await this.client.request({ action: 'signout' }); this.repaint(); return; } - if (command === 'Check Desktop identity') { await this.client.request({ action: 'probe' }); return; } if (command.includes('relay')) { const dialog = new ProviderDialog(this.renderer, 'CHOOSE A RELAY', [{ label: 'Relay URL', value: this.relay || 'wss://' }], () => {}, 'Use relay'); this.dialog = dialog; const result = await dialog.done; if (this.dialog === dialog) this.dialog = undefined; @@ -114,19 +114,23 @@ export class OwnerPanel { } this.repaint(); } + private probe() { + if (this.shown && this.state.ownerActive && !this.snapshot.signedIn && !this.dialog && !this.state.helpOpen && this.snapshot.phase !== 'busy') void this.client.request({ action: 'probe' }); + } paint() { const owner = this.state.ownerActive; const shown = !this.state.belowMinimum && (owner || this.state.protectedSection && !(this.state.activeSection === 0 && this.snapshot.signedIn)); const left = (this.wasOwner && !owner) || (this.shown && !shown); - const entered = owner && !this.wasOwner; this.wasOwner = owner; this.shown = shown; this.box.visible = shown; if (left) { this.generation++; this.dialog?.cancel(); this.dialog = undefined; if (this.snapshot.phase === 'busy') this.client.cancel(); this.requestedSection = undefined; this.prior = undefined; this.index = 0; } + const detecting = shown && owner && !this.snapshot.signedIn; + if (!detecting && this.timer) { clearInterval(this.timer); this.timer = undefined; } + if (detecting && !this.timer) { this.timer = setInterval(() => this.probe(), 3000); this.probe(); } if (!shown) return; - if (entered && !this.snapshot.signedIn && this.snapshot.desktop === 'unknown') void this.client.request({ action: 'probe' }); const width = this.renderer.width - 4, height = this.renderer.height - 5; const wrap = (value: string) => { const rows: string[] = []; for (const line of value.split('\n')) { let rest = line; while (rest.length > width) { const at = rest.lastIndexOf(' ', width); const end = at > 0 ? at : width; rows.push(rest.slice(0, end)); rest = rest.slice(end).trimStart(); } rows.push(rest); } return rows; }; const truncate = (value: string) => { const limit = width - (owner && this.requestedSection !== undefined ? 19 : 8); return value.length > limit ? value.slice(0, Math.ceil((limit - 1) / 2)) + '…' + value.slice(-Math.floor((limit - 1) / 2)) : value; }; @@ -154,5 +158,5 @@ export class OwnerPanel { this.note.content = wrap(this.selectionNote(commands[this.index] ?? '')).join('\n'); this.dialog?.paint(); } - dispose() { this.generation++; this.dialog?.cancel(); this.unsubscribe(); this.client.dispose(); } + dispose() { if (this.timer) clearInterval(this.timer); this.generation++; this.dialog?.cancel(); this.unsubscribe(); this.client.dispose(); } } diff --git a/beehive/src/owner-service.ts b/beehive/src/owner-service.ts index 1582bb19c7..dbc3d997c9 100644 --- a/beehive/src/owner-service.ts +++ b/beehive/src/owner-service.ts @@ -14,7 +14,7 @@ import type { OwnerClient, OwnerRequest, OwnerSnapshot } from './owner-protocol. export type HostDependencies = { credential: typeof managerCredential; status: typeof serviceStatus; start: typeof startService; stop: typeof stopService; reset: typeof resetHost }; const hostDependencies: HostDependencies = { credential: managerCredential, status: serviceStatus, start: startService, stop: stopService, reset: resetHost }; -const missing = 'Desktop identity unavailable. Open and unlock Buzz, then check again or provide an owner nsec.'; +const missing = 'Desktop identity unavailable. Open and unlock Buzz, or provide an owner nsec. Availability updates automatically.'; /** Offline Node authority. Session keys have no persistence, snapshot, or relay path. */ export class OwnerService implements OwnerClient { private current: OwnerSnapshot = { signedIn: false, desktop: 'unknown', desktopReason: 'Identity availability has not been checked.', phase: 'idle', message: 'Sign in to manage Host and Agents.', secretLength: 0 }; @@ -22,6 +22,7 @@ export class OwnerService implements OwnerClient { private input = ''; private session?: string; private active?: AbortController; + private probing?: AbortController; private generation = 0; private hostOperation = false; private disposed = false; @@ -49,62 +50,74 @@ export class OwnerService implements OwnerClient { else if (action === 'append') this.input = (this.input + value.replace(/[\x00-\x1f\x7f]/g, '')).slice(0, 256); this.current.secretLength = this.input.length; this.publish(); } + /** Availability checks never own input or replace the last operation result. */ + private async probe() { + if (this.probing || this.active || this.current.signedIn) return false; + const abort = new AbortController(); this.probing = abort; + try { + const result = await this.desktop(true, abort.signal); + if (abort.signal.aborted || this.disposed || this.probing !== abort) return false; + this.current.desktop = result.available ? 'available' : 'unavailable'; + this.current.desktopReason = result.available ? 'Desktop entry detected. Sign-in checks identity and access.' : missing; + return true; + } catch { + if (!abort.signal.aborted && !this.disposed && this.probing === abort) { + this.current.desktop = 'unavailable'; this.current.desktopReason = missing; + } + return false; + } finally { if (this.probing === abort) this.probing = undefined; this.publish(); } + } async request(request: OwnerRequest) { if (this.disposed) return false; + if (request.action === 'probe') return this.probe(); if (request.action.startsWith('host-')) return this.hostRequest(request); if (request.action === 'signout') { this.cancel(); this.session = undefined; this.current.signedIn = false; this.current.owner = undefined; this.current.host = undefined; this.current.hostMessage = undefined; this.current.hostPhase = 'idle'; this.current.message = 'Signed out. Host and agents keep running.'; this.publish(); return true; } if (this.active || this.current.signedIn) return false; + this.probing?.abort(); this.probing = undefined; const abort = new AbortController(), generation = ++this.generation; - this.active = abort; this.current.phase = 'busy'; this.current.message = request.action === 'probe' ? 'Checking Desktop identity…' : 'Signing in… Esc cancels.'; this.publish(); + this.active = abort; this.current.phase = 'busy'; this.current.message = 'Signing in… Esc cancels.'; this.publish(); let error = 'Sign-in failed. No session was opened. Check the retained owner configuration and try again.'; const check = () => { abort.signal.throwIfAborted(); if (generation !== this.generation || this.disposed) throw Error('Cancelled'); }; try { - if (request.action === 'probe') { - const result = await this.desktop(true, abort.signal); check(); - this.current.desktop = result.available ? 'available' : 'unavailable'; - this.current.desktopReason = result.available ? 'Desktop entry detected. Sign-in checks identity and access.' : missing; - this.current.message = result.available ? 'Buzz Desktop identity is available.' : missing; - } else { - const before = this.binding(); - const relay = before?.relay ?? request.relay?.trim(); - error = 'Enter a relay URL using wss:// (or ws://localhost for local testing). Nothing was changed.'; - if (!relay) throw Error(); - const url = new URL(relay); - if (url.username || url.password || url.hash || !(url.protocol === 'wss:' || (url.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error(); - let secret: string; - if (request.action === 'signin-desktop') { - error = 'Desktop key access failed or was cancelled. Unlock Keychain or provide owner nsec. No session opened.'; - const result = await this.desktop(false, abort.signal); check(); - if (!result.secret) throw Error(); - secret = result.secret; - } else if (request.action === 'signin-nsec') { - error = 'Enter a valid owner nsec. No session was opened and nothing was saved.'; - secret = agentNsec(this.input); - } else throw Error('Unsupported action'); - const owner = publicKey(secret); check(); - error = 'Identity does not match this owner. Use the matching key. Owner and relay were not changed.'; - const now = this.binding(); - if (before && !now || now && (now.owner !== owner || now.relay !== relay)) throw Error(); - error = 'Owner routing changed or was not saved. Reopen sign-in and check configuration. No session opened.'; - if (!now) createControllerConfig(join(this.home, '.beehive', 'owner'), owner, relay); - // Read back the sole public authority after first-use activation. - const saved = this.binding(); - if (saved?.owner !== owner || saved.relay !== relay) throw Error(); + const before = this.binding(); + const relay = before?.relay ?? request.relay?.trim(); + error = 'Enter a relay URL using wss:// (or ws://localhost for local testing). Nothing was changed.'; + if (!relay) throw Error(); + const url = new URL(relay); + if (url.username || url.password || url.hash || !(url.protocol === 'wss:' || (url.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error(); + let secret: string; + if (request.action === 'signin-desktop') { + error = 'Desktop key access failed or was cancelled. Unlock Keychain or provide owner nsec. No session opened.'; + const result = await this.desktop(false, abort.signal); check(); + if (!result.secret) throw Error(); + secret = result.secret; + } else if (request.action === 'signin-nsec') { + error = 'Enter a valid owner nsec. No session was opened and nothing was saved.'; + secret = agentNsec(this.input); + } else throw Error('Unsupported action'); + const owner = publicKey(secret); check(); + error = 'Identity does not match this owner. Use the matching key. Owner and relay were not changed.'; + const now = this.binding(); + if (before && !now || now && (now.owner !== owner || now.relay !== relay)) throw Error(); + error = 'Owner routing changed or was not saved. Reopen sign-in and check configuration. No session opened.'; + if (!now) createControllerConfig(join(this.home, '.beehive', 'owner'), owner, relay); + // Read back the sole public authority after first-use activation. + const saved = this.binding(); + if (saved?.owner !== owner || saved.relay !== relay) throw Error(); + check(); + const directory = join(this.home, '.beehive', 'host'); + error = 'Host identity creation failed. Binding may be saved. Retry sign-in with the same owner and relay; no Host was started.'; + if (!existsSync(join(directory, 'host-identity.json')) && !pendingHostReset(directory)) { + await this.hosts.credential({ action: 'configure', directory, ownerDirectory: join(this.home, '.beehive', 'owner'), label: hostname().slice(0, 128), owner, relay }, abort.signal); check(); - const directory = join(this.home, '.beehive', 'host'); - error = 'Host identity creation failed. Binding may be saved. Retry sign-in with the same owner and relay; no Host was started.'; - if (!existsSync(join(directory, 'host-identity.json')) && !pendingHostReset(directory)) { - await this.hosts.credential({ action: 'configure', directory, ownerDirectory: join(this.home, '.beehive', 'owner'), label: hostname().slice(0, 128), owner, relay }, abort.signal); - check(); - } - const finalBinding = this.binding(); - if (finalBinding?.owner !== owner || finalBinding.relay !== relay) throw Error('Binding changed'); - check(); this.session = secret; this.current.signedIn = true; this.current.owner = nip19.npubEncode(owner); this.projectBinding(); - this.current.message = pendingHostReset(directory) ? 'Signed in. Reset incomplete; open Host to finish Reset Host.' : 'Signed in. Host is configured. No service or agent was started.'; } + const finalBinding = this.binding(); + if (finalBinding?.owner !== owner || finalBinding.relay !== relay) throw Error('Binding changed'); + check(); this.session = secret; this.current.signedIn = true; this.current.owner = nip19.npubEncode(owner); this.projectBinding(); + this.current.message = pendingHostReset(directory) ? 'Signed in. Reset incomplete; open Host to finish Reset Host.' : 'Signed in. Host is configured. No service or agent was started.'; this.current.phase = 'idle'; return true; } catch { if (generation === this.generation && !this.disposed) { this.current.phase = 'error'; this.current.message = error; } @@ -158,18 +171,18 @@ export class OwnerService implements OwnerClient { const final = await project(); if (request.action === 'host-start' && final.state !== 'running' || request.action === 'host-stop' && final.state !== 'stopped') throw Error('Unconfirmed result'); this.current.hostPhase = 'idle'; - this.current.hostMessage = pendingHostReset(directory) ? 'Reset incomplete. Retry Reset Host to finish.' : final.state === 'unknown' ? 'Status unknown. Check the local service, then Refresh status. Start and Reset are blocked.' : request.action === 'host-start' ? 'Host started. No agent was started.' : request.action === 'host-stop' ? 'Host and its agents stopped.' : ''; + this.current.hostMessage = pendingHostReset(directory) ? 'Reset incomplete. Retry Reset Host to finish.' : final.state === 'unknown' ? 'Status unknown. Check the local service, then Retry. Start and Reset are blocked.' : request.action === 'host-start' ? 'Host started. No agent was started.' : request.action === 'host-stop' ? 'Host and its agents stopped.' : ''; return true; } catch { if (generation === this.generation && !this.disposed && this.current.signedIn) { try { await project(); } catch { if (generation === this.generation) this.current.host = undefined; } if (generation !== this.generation || this.disposed || !this.current.signedIn) return false; this.current.hostPhase = 'error'; - this.current.hostMessage = pendingHostReset(directory) ? 'Reset incomplete. Keep local files. Retry Reset Host.' : 'Operation unconfirmed. Refresh status before retrying.'; + this.current.hostMessage = pendingHostReset(directory) ? 'Reset incomplete. Keep local files. Retry Reset Host.' : 'Operation unconfirmed. Use Retry to check status before trying again.'; } return false; } finally { if (this.active === abort) this.active = undefined; this.hostOperation = false; this.publish(); } } - cancel() { this.generation++; this.active?.abort(); if (!this.hostOperation) this.active = undefined; this.input = ''; this.current.secretLength = 0; this.current.phase = 'idle'; if (this.current.hostPhase === 'busy') { this.current.hostPhase = 'idle'; this.current.hostMessage = 'Stopped waiting. Changes may already be saved. Refresh status before retrying.'; } this.current.message = 'Stopped waiting. Configuration may already be saved; sign in again with the same owner and relay.'; this.publish(); } + cancel() { this.probing?.abort(); this.probing = undefined; this.generation++; this.active?.abort(); if (!this.hostOperation) this.active = undefined; this.input = ''; this.current.secretLength = 0; this.current.phase = 'idle'; if (this.current.hostPhase === 'busy') { this.current.hostPhase = 'idle'; this.current.hostMessage = 'Stopped waiting. Changes may already be saved. Status will update automatically.'; } this.current.message = 'Stopped waiting. Configuration may already be saved; sign in again with the same owner and relay.'; this.publish(); } dispose() { this.cancel(); this.session = undefined; this.current.owner = undefined; this.current.signedIn = false; this.current.host = undefined; this.disposed = true; this.listeners.clear(); } } diff --git a/beehive/test/owner-service.test.ts b/beehive/test/owner-service.test.ts index f6a86e5a39..920446caef 100644 --- a/beehive/test/owner-service.test.ts +++ b/beehive/test/owner-service.test.ts @@ -114,3 +114,34 @@ test('cancelled read cannot block or overwrite a replacement session', async () assert.equal(f.service.snapshot().signedIn, true); } finally { f.close(); } }); + +test('passive Desktop checks preserve input and sign-in errors and fail closed', async () => { + let fail = false; + const f = fixture(async () => { if (fail) throw Error('private probe error'); return { available: true }; }); + try { + assert.equal(await f.service.request({ action: 'signin-nsec', relay }), false); + const error = f.service.snapshot().message; + f.service.secret('append', nsec); + for (fail of [false, true]) { + assert.equal(await f.service.request({ action: 'probe' }), !fail); + assert.equal(f.service.snapshot().desktop, fail ? 'unavailable' : 'available'); + assert.equal(f.service.snapshot().message, error); + assert.equal(f.service.snapshot().phase, 'error'); + assert.equal(f.service.snapshot().secretLength, nsec.length); + } + assert.equal(await f.service.request({ action: 'signin-nsec', relay }), true); + } finally { f.close(); } +}); +test('sign-in supersedes a pending passive probe without losing input or accepting its late result', async () => { + let finish!: (value: { available: boolean }) => void; + const f = fixture(async () => new Promise(resolve => { finish = resolve; })); + try { + const pending = f.service.request({ action: 'probe' }); + assert.equal(await f.service.request({ action: 'probe' }), false); + f.service.secret('append', nsec); + assert.equal(await f.service.request({ action: 'signin-nsec', relay }), true); + finish({ available: true });assert.equal(await pending, false); + assert.equal(f.service.snapshot().signedIn, true); + assert.equal(f.service.snapshot().desktop, 'unknown'); + } finally { f.close(); } +}); diff --git a/beehive/test/shell-host.test.ts b/beehive/test/shell-host.test.ts index 663099fc84..ac79ed73d1 100644 --- a/beehive/test/shell-host.test.ts +++ b/beehive/test/shell-host.test.ts @@ -7,7 +7,7 @@ import { HarnessInventoryController } from '../src/harness-inventory.ts'; import { localOwner } from '../src/owner-client.ts'; import { serviceStatus, stopService } from '../src/host-service.ts'; -for (const [width, height] of [[120,40],[60,20]]) test(`Host real IPC and detached lifecycle, confirmation, reset recovery at ${width}x${height}`, async () => { +for (const [width, height] of [[120,40],[60,20]]) test(`Host real IPC and detached lifecycle, confirmation, reset recovery at ${width}x${height}`, { timeout: 15000 }, async () => { const home = mkdtempSync('/tmp/owner-tui-fixture-host-'), directory = home+'/.beehive/host'; writeFileSync(home+'/desktop-mode','available'); writeFileSync(home+'/host-service-mode','real'); const client=localOwner({helper:new URL('./owner-tui-fixture-child.ts',import.meta.url),environment:{HOME:home,BEEHIVE_HOME:home,PATH:process.env.PATH}}); @@ -21,16 +21,16 @@ for (const [width, height] of [[120,40],[60,20]]) test(`Host real IPC and detach await wait(()=>!client.snapshot().message.includes('Loading')); assert.equal(await client.request({action:'signin-desktop',relay:'wss://fixture.example'}),true); await wait(()=>client.snapshot().host?.state==='stopped' && client.snapshot().hostPhase==='idle'); - assert.equal(shell.state.splitPane,false);assert.match(ui.captureCharFrame(),/Agents running\s+0/); + assert.doesNotMatch(ui.captureCharFrame(),/Refresh status|Retry/);assert.equal(shell.state.splitPane,false);assert.match(ui.captureCharFrame(),/Agents running\s+0/); await click('Reset Host');assert.match(ui.captureCharFrame(),/RESET HOST/);await esc();assert.ok(existsSync(directory+'/host-identity.json')); await click('Start Host');await wait(()=>client.snapshot().host?.state==='running' && client.snapshot().hostPhase==='idle'); - assert.match(ui.captureCharFrame(),/Stop Host/);assert.doesNotMatch(ui.captureCharFrame(),/Reset Host/);assert.equal(client.snapshot().host!.agents,0); + assert.doesNotMatch(ui.captureCharFrame(),/Refresh status|Retry/);assert.match(ui.captureCharFrame(),/Stop Host/);assert.doesNotMatch(ui.captureCharFrame(),/Reset Host/);assert.equal(client.snapshot().host!.agents,0); await click('Stop Host');assert.match(ui.captureCharFrame(),/every agent/);await click('Keep running');await ui.renderOnce();assert.equal((await serviceStatus(directory)).state,'running'); await client.request({action:'signout'});await ui.renderOnce();assert.equal((await serviceStatus(directory)).state,'running');assert.doesNotMatch(ui.captureCharFrame(),/Stop Host/); assert.equal(await client.request({action:'signin-desktop'}),true);await wait(()=>client.snapshot().host?.state==='running' && client.snapshot().hostPhase==='idle'); await click('Stop Host');ui.mockInput.pressEnter();await wait(()=>client.snapshot().host?.state==='stopped' && client.snapshot().hostPhase==='idle'); - mkdirSync(directory+'/host.lock');await client.request({action:'host-status'});await ui.renderOnce();assert.match(ui.captureCharFrame(),/UNKNOWN/);assert.doesNotMatch(ui.captureCharFrame(),/Start Host|Reset Host/); - rmSync(directory+'/host.lock',{recursive:true});await click('Refresh status');await wait(()=>client.snapshot().host?.state==='stopped' && client.snapshot().hostPhase==='idle'); + mkdirSync(directory+'/host.lock');await wait(()=>client.snapshot().host?.state==='unknown' && client.snapshot().hostPhase==='idle');assert.match(ui.captureCharFrame(),/UNKNOWN/);assert.doesNotMatch(ui.captureCharFrame(),/Start Host|Reset Host/); + rmSync(directory+'/host.lock',{recursive:true});await click('Retry');await wait(()=>client.snapshot().host?.state==='stopped' && client.snapshot().hostPhase==='idle'); writeFileSync(home+'/reset-denied','true');await click('Reset Host');await click('Reset Host');await wait(()=>client.snapshot().hostPhase==='error');assert.match(ui.captureCharFrame(),/Reset incomplete/);assert.doesNotMatch(ui.captureCharFrame(),/Start Host/); rmSync(home+'/reset-denied');await click('Reset Host');ui.mockInput.pressEnter();await wait(()=>!client.snapshot().signedIn);assert.match(ui.captureCharFrame(),/Sign in to view/);assert.equal(client.snapshot().relay,undefined);assert.equal(existsSync(directory+'/host-identity.json'),false); } finally { diff --git a/beehive/test/shell-owner-ipc.test.ts b/beehive/test/shell-owner-ipc.test.ts index 06af239420..ec382af750 100644 --- a/beehive/test/shell-owner-ipc.test.ts +++ b/beehive/test/shell-owner-ipc.test.ts @@ -52,3 +52,31 @@ for (const [width, height] of [[120,40],[60,20]]) test(`real owner IPC: form fai assert.match(ui.captureCharFrame(),/Start Host/); assert.doesNotMatch(ui.captureCharFrame(),/Continue to|Stay here/); } finally {shell.close();rmSync(home,{recursive:true,force:true});} }); + +for (const [width, height] of [[120,40],[60,20]]) test(`Desktop availability updates automatically in the open sign-in screen at ${width}x${height}`, { timeout: 15000 }, async () => { + const home=mkdtempSync('/tmp/owner-tui-fixture-detect-'); + const mode=(value:string)=>writeFileSync(home+'/desktop-mode',value); + mode('missing'); + const client=localOwner({helper:new URL('./owner-tui-fixture-child.ts',import.meta.url),environment:{HOME:home,BEEHIVE_HOME:home,PATH:process.env.PATH}}); + const ui=await createTestRenderer({width,height,exitOnCtrlC:false}); + const inventory=new HarnessInventoryController({read:()=>({version:1,revision:0,agents:[],providers:[],runtimes:[]}),save:c=>c},async()=>[]); + const shell=new OpenTuiShell(ui.renderer,inventory,undefined,undefined,client); + const wait=async(fn:()=>boolean)=>{for(let i=0;i<450;i++){if(fn()){await ui.renderOnce();return;}await new Promise(r=>setTimeout(r,10));}assert.fail(JSON.stringify(client.snapshot()));}; + try { + await wait(()=>!client.snapshot().message.includes('Loading')); + for(let i=0;i<4;i++)ui.mockInput.pressArrow('right'); + await wait(()=>client.snapshot().desktop==='unavailable'); + assert.doesNotMatch(ui.captureCharFrame(),/Check Desktop identity/); + // Choose a relay, then Enter on unavailable Desktop must not sign in. + ui.mockInput.pressEnter();ui.mockInput.pressEnter();await ui.renderOnce(); + ui.mockInput.typeText('fixture.example');ui.mockInput.pressTab();ui.mockInput.pressEnter(); + await new Promise(r=>setTimeout(r,0));ui.mockInput.pressEnter(); + assert.equal(client.snapshot().signedIn,false); + mode('available');await wait(()=>client.snapshot().desktop==='available'); + mode('missing');await wait(()=>client.snapshot().desktop==='unavailable'); + ui.mockInput.pressEnter();assert.equal(client.snapshot().signedIn,false); + mode('available');await wait(()=>client.snapshot().desktop==='available'); + ui.mockInput.pressEnter();await wait(()=>client.snapshot().signedIn && client.snapshot().host?.state==='stopped'); + assert.match(ui.captureCharFrame(),/Start Host/);assert.doesNotMatch(ui.captureCharFrame(),/Refresh status|Check Desktop identity/); + } finally {shell.close();rmSync(home,{recursive:true,force:true});} +}); diff --git a/beehive/test/shell-owner.test.ts b/beehive/test/shell-owner.test.ts index 7e590a17e6..5c68e062c9 100644 --- a/beehive/test/shell-owner.test.ts +++ b/beehive/test/shell-owner.test.ts @@ -15,7 +15,7 @@ function fixture() { const emit = () => { for (const fn of listeners) fn(structuredClone(snapshot)); }; const client: OwnerClient = { snapshot: () => structuredClone(snapshot), subscribe(fn) { listeners.add(fn); fn(structuredClone(snapshot)); return () => { listeners.delete(fn); }; }, - async request(r) { requests.push(r); if (r.action === 'host-status') { snapshot.host = { name: 'Fixture Host', host: 'a'.repeat(64), owner: 'b'.repeat(64), relay: snapshot.relay!, state: 'stopped', agents: 0, revision: 'fixture', resetPending: false }; } else if (r.action.startsWith('signin')) { snapshot.signedIn = true; snapshot.owner = 'npub1fixture'; snapshot.secretLength = 0; snapshot.message = 'Signed in. No operation was started.'; } else if (r.action === 'signout') { snapshot.signedIn = false; snapshot.owner = undefined; snapshot.message = 'Signed out. Host and agents keep running.'; } emit(); return true; }, + async request(r) { if (r.action === 'probe') return true; requests.push(r); if (r.action === 'host-status') { snapshot.host = { name: 'Fixture Host', host: 'a'.repeat(64), owner: 'b'.repeat(64), relay: snapshot.relay!, state: 'stopped', agents: 0, revision: 'fixture', resetPending: false }; } else if (r.action.startsWith('signin')) { snapshot.signedIn = true; snapshot.owner = 'npub1fixture'; snapshot.secretLength = 0; snapshot.message = 'Signed in. No operation was started.'; } else if (r.action === 'signout') { snapshot.signedIn = false; snapshot.owner = undefined; snapshot.message = 'Signed out. Host and agents keep running.'; } emit(); return true; }, secret(action, value) { snapshot.secretLength = action === 'clear' ? 0 : action === 'backspace' ? Math.max(0, snapshot.secretLength - 1) : snapshot.secretLength + (value?.length ?? 0); emit(); }, cancel() {}, dispose() {}, }; @@ -52,7 +52,7 @@ for (const [width,height] of [[120,40],[60,20]]) test(`owner unavailable identit try { for(let i=0;i<4;i++) ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await render(ui); - assert.equal(f.requests.length,0); assert.match(ui.captureCharFrame(),/No usable Buzz/); + assert.doesNotMatch(ui.captureCharFrame(),/Check Desktop identity/);assert.equal(f.requests.length,0); assert.match(ui.captureCharFrame(),/No usable Buzz/); const lines=ui.captureCharFrame().split('\n'),y=lines.findIndex(l=>l.includes('Provide owner nsec')); await ui.mockMouse.click(lines[y]!.indexOf('Provide owner nsec'),y);await render(ui); assert.match(ui.captureCharFrame(),/PROVIDE OWNER NSEC/); assert.match(ui.captureCharFrame(),/Enter owner nsec/); @@ -85,7 +85,7 @@ for (const [width,height] of [[120,40],[60,20]]) for (const target of [0,1]) tes for (const cancel of ['escape', 'navigate']) test(`late successful sign-in does not redirect after ${cancel}`, async () => { const ui=await createTestRenderer({width:60,height:20,exitOnCtrlC:false}),f=fixture(); let finish!: (ok:boolean)=>void; - f.client.request=async r=>{ f.requests.push(r); f.snapshot.phase='busy'; f.emit(); return new Promise(resolve=>{finish=resolve;}); }; + f.client.request=async r=>{ if(r.action==='probe') return true; f.requests.push(r); f.snapshot.phase='busy'; f.emit(); return new Promise(resolve=>{finish=resolve;}); }; const shell=new OpenTuiShell(ui.renderer,f.inventory,undefined,undefined,f.client); try { ui.mockInput.pressEnter();ui.mockInput.pressEnter();ui.mockInput.pressEnter();await render(ui);