Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions autobot-frontend/src/components/__tests__/TerminalModals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright 2025-2026 mrveiss
// SPDX-License-Identifier: Apache-2.0
// AutoBot - AI-Powered Automation Platform
// Author: mrveiss
/**
* TerminalModals reports the parent's real outcome for each action, never a
* success after a fixed delay (#16285).
*/

import { describe, it, expect, afterEach, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import TerminalModals from '../terminal/TerminalModals.vue'
import en from '@/i18n/locales/en.json'

vi.mock('@/utils/debugUtils', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))

type ModalsProps = InstanceType<typeof TerminalModals>['$props']

const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } })
const modals = en.terminal.modals

// Renders the dialog body and its actions whenever the modal is open.
const BaseModalStub = {
props: ['modelValue'],
template: '<div v-if="modelValue"><slot /><slot name="actions" /></div>',
}

const ok = () => Promise.resolve()

function mountModals(props: Partial<ModalsProps>) {
return mount(TerminalModals, {
props: {
showReconnectModal: false,
showCommandConfirmation: false,
showKillConfirmation: false,
showLegacyModal: false,
pendingCommand: 'rm -rf build',
pendingCommandRisk: 'high',
pendingCommandReasons: [],
runningProcesses: [{ pid: 1, command: 'sleep 60' }],
pendingWorkflowStep: { stepNumber: 1, totalSteps: 2, command: 'ls', description: 'List files' },
reconnectAction: ok,
executeCommandAction: ok,
emergencyKillAction: ok,
confirmStepAction: ok,
skipStepAction: ok,
manualControlAction: ok,
...props,
},
global: { plugins: [i18n], stubs: { BaseModal: BaseModalStub } },
})
}

async function press(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes(label))
if (!button) throw new Error(`no button labelled "${label}"`)
await button.trigger('click')
await flushPromises()
}

const errorText = (wrapper: VueWrapper) => wrapper.find('.error-text')
const successText = (wrapper: VueWrapper) => wrapper.find('.success-text')

describe('TerminalModals (#16285)', () => {
afterEach(() => {
vi.useRealTimers()
})

it("shows the error when the parent's action rejects, and reports no success", async () => {
const wrapper = mountModals({
showCommandConfirmation: true,
executeCommandAction: () => Promise.reject(new Error('permission denied')),
})

await press(wrapper, modals.executeCommand)

expect(errorText(wrapper).text()).toBe('permission denied')
expect(successText(wrapper).exists()).toBe(false)
})

it("shows the error when the parent's action throws synchronously", async () => {
const wrapper = mountModals({
showKillConfirmation: true,
emergencyKillAction: () => {
throw new Error('no such session')
},
})

await press(wrapper, modals.killAll)

expect(errorText(wrapper).text()).toBe('no such session')
expect(successText(wrapper).exists()).toBe(false)
})

it('reports success only once the action settles, with no fixed delay', async () => {
let settle: () => void = () => {}
const wrapper = mountModals({
showReconnectModal: true,
reconnectAction: () =>
new Promise<void>((resolve) => {
settle = resolve
}),
})

await press(wrapper, en.terminal.reconnect)
expect(successText(wrapper).exists()).toBe(false)

settle()
await flushPromises()

expect(successText(wrapper).text()).toBe(modals.reconnectSucceeded)
})

it('fails the action at its deadline when the parent never settles', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = mountModals({
showCommandConfirmation: true,
executeCommandAction: () => new Promise(() => {}),
})

await press(wrapper, modals.executeCommand)
await vi.advanceTimersByTimeAsync(30_000)
await flushPromises()

expect(errorText(wrapper).text()).toBe(modals.commandTimedOut)
expect(successText(wrapper).exists()).toBe(false)
})

it('clears every pending auto-hide timer on unmount (#16315)', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = mountModals({
showCommandConfirmation: true,
executeCommandAction: () => Promise.reject(new Error('boom')),
})

await press(wrapper, modals.executeCommand)
expect(errorText(wrapper).text()).toBe('boom')
expect(vi.getTimerCount()).toBeGreaterThan(0)

wrapper.unmount()

expect(vi.getTimerCount()).toBe(0)
})

it("a later message in the same slot outlives the earlier message's auto-hide timer (#16315)", async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
let callCount = 0
const wrapper = mountModals({
showCommandConfirmation: true,
executeCommandAction: () => Promise.reject(new Error(callCount++ === 0 ? 'first' : 'second')),
})

await press(wrapper, modals.executeCommand)
expect(errorText(wrapper).text()).toBe('first')

await vi.advanceTimersByTimeAsync(3_000)
await press(wrapper, modals.executeCommand)
expect(errorText(wrapper).text()).toBe('second')

// The first message's own 10s auto-hide would fire here (10s after the
// first press). It must not blank the second message, which still has
// 7s left of its own 10s window.
await vi.advanceTimersByTimeAsync(7_000)
expect(errorText(wrapper).text()).toBe('second')

// The second message's own timer fires 10s after its own press.
await vi.advanceTimersByTimeAsync(3_000)
expect(errorText(wrapper).exists()).toBe(false)
})

it('shows a workflow-step failure in the step modal', async () => {
const wrapper = mountModals({
showLegacyModal: true,
skipStepAction: () => Promise.reject(new Error('step already ran')),
})

await press(wrapper, modals.skipStep)

expect(errorText(wrapper).text()).toBe('step already ran')
expect(successText(wrapper).exists()).toBe(false)
})

it('reports manual control once the parent has taken it', async () => {
const manualControlAction = vi.fn(() => Promise.resolve())
const wrapper = mountModals({ showLegacyModal: true, manualControlAction })

await press(wrapper, modals.takeManualControl)

expect(manualControlAction).toHaveBeenCalledOnce()
expect(successText(wrapper).text()).toBe(modals.manualControlTaken)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -471,4 +471,83 @@ describe('TerminalWindow', () => {
expect(closeButton?.classList.contains('danger')).toBe(true)
})
})

describe('TerminalModals wiring (#16285)', () => {
const ACTIONS = [
'reconnectAction',
'executeCommandAction',
'emergencyKillAction',
'confirmStepAction',
'skipStepAction',
'manualControlAction',
]

// Captures the actions TerminalWindow hands its modals, so each one can be driven directly.
const captured: { props: Record<string, unknown> } = { props: {} }
const TerminalModalsStub = {
name: 'TerminalModals',
props: ACTIONS,
setup(props: Record<string, unknown>) {
captured.props = props
return {}
},
template: '<div data-testid="terminal-modals-stub" />',
}

const renderReady = async () => {
renderComponent(TerminalWindow, {
router: true,
global: {
stubs: {
AdvancedStepConfirmationModal: { template: '<div data-testid="step-modal-stub" />' },
CompletionSuggestions: { template: '<div data-testid="completion-stub" />' },
TerminalModals: TerminalModalsStub,
},
},
})
// Let the mount-time connect settle before a test queues its own outcome.
await waitFor(() => {
expect(mockConnect).toHaveBeenCalled()
})
}

const action = (name: string) => captured.props[name] as () => Promise<unknown>

it('renders TerminalModals and hands it every action', async () => {
await renderReady()

expect(screen.getByTestId('terminal-modals-stub')).toBeInTheDocument()
for (const name of ACTIONS) {
expect(typeof captured.props[name], name).toBe('function')
}
})

it('rejects the command action when sending fails, so the modal can show it', async () => {
await renderReady()
mockSendInput.mockRejectedValueOnce(new Error('socket closed'))

await expect(action('executeCommandAction')()).rejects.toThrow('socket closed')
})

it('resolves the command action once the command is sent', async () => {
await renderReady()
mockSendInput.mockResolvedValueOnce(undefined)

await expect(action('executeCommandAction')()).resolves.toBeUndefined()
})

it('rejects the kill action when the kill fails', async () => {
await renderReady()
mockSendInput.mockRejectedValueOnce(new Error('kill failed'))

await expect(action('emergencyKillAction')()).rejects.toThrow('kill failed')
})

it('rejects the reconnect action when the connection fails', async () => {
await renderReady()
mockConnect.mockRejectedValueOnce(new Error('connection refused'))

await expect(action('reconnectAction')()).rejects.toThrow('connection refused')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ const meta = {
title: 'Components/Terminal/TerminalModals',
component: TerminalModals,
tags: ['autodocs'],
// Each action resolves at once, so every story shows the success path (#16285).
args: {
reconnectAction: () => Promise.resolve(),
executeCommandAction: () => Promise.resolve(),
emergencyKillAction: () => Promise.resolve(),
confirmStepAction: () => Promise.resolve(),
skipStepAction: () => Promise.resolve(),
manualControlAction: () => Promise.resolve(),
},
argTypes: {
showReconnectModal: {
control: 'boolean',
Expand Down
Loading
Loading