Reproduce Discovery outcomes crossing guest and run lifecycles

Exercise late HTTP completion, retained background safety errors, pending refreshes, missing-target state, timer ownership and notes saves against the existing production state owner. These controls isolate truthful Discovery use without sending guest commands or claiming native backup acceptance.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 21:16:17 +01:00
parent efbbb73137
commit bc3f4d81d1
@@ -0,0 +1,280 @@
import { createSignal } from 'solid-js';
import { cleanup, render, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResourceDiscovery } from '@/types/discovery';
import { eventBus } from '@/stores/events';
import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState';
import * as api from '@/api/discovery';
import { useDiscoveryTabState } from '../useDiscoveryTabState';
vi.mock('@/api/discovery', () => ({
getDiscovery: vi.fn(async () => null),
getDiscoveryInfo: vi.fn(async () => ({
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
})),
getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })),
triggerDiscovery: vi.fn(),
updateDiscoveryNotes: vi.fn(async () => undefined),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
const saved = (id = '100'): ResourceDiscovery => ({
id: `vm:node-agent:${id}`,
resource_type: 'vm',
resource_id: id,
target_id: 'node-agent',
hostname: `guest-${id}`,
service_type: 'home-assistant',
service_name: `Saved service ${id}`,
service_version: 'fixture',
category: 'home_automation',
cli_access: '',
user_secrets: {},
ai_reasoning: 'Synthetic saved evidence',
scan_duration: 0,
facts: [],
config_paths: [],
data_paths: [],
log_paths: [],
ports: [],
user_notes: '',
discovered_at: '2026-10-03T12:00:00Z',
updated_at: '2026-10-03T12:00:00Z',
confidence: 0.9,
});
async function mount(initialAgent = 'node-agent') {
const [id, setId] = createSignal('100');
const [agent, setAgent] = createSignal(initialAgent);
let state!: ReturnType<typeof useDiscoveryTabState>;
const view = render(() => {
state = useDiscoveryTabState({
resourceType: 'vm',
get agentId() {
return agent();
},
get resourceId() {
return id();
},
get hostname() {
return `guest-${id()}`;
},
});
return <span>Discovery lifecycle</span>;
});
await waitFor(() => expect(state.discoveryInfo.loading).toBe(false));
await waitFor(() => expect(state.discovery.loading).toBe(false));
return { state, setId, setAgent, dispose: view.unmount };
}
beforeEach(() => {
vi.resetAllMocks();
resetAIRuntimeState();
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
vi.mocked(api.getDiscovery).mockImplementation(async (_type, _agent, id) => saved(id));
vi.mocked(api.getDiscoveryInfo).mockResolvedValue({
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
});
vi.mocked(api.getConnectedAgents).mockResolvedValue({ count: 0, agents: [] });
vi.mocked(api.triggerDiscovery).mockResolvedValue(saved());
});
afterEach(() => {
cleanup();
resetAIRuntimeState();
vi.useRealTimers();
});
describe('Discovery outcome ownership', () => {
it.each(['success', 'failure'] as const)(
'does not apply an old target HTTP %s to the new guest or its in-flight run',
async (outcome) => {
const old = deferred<ResourceDiscovery>();
const current = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery)
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(current.promise);
const { state, setId } = await mount();
const first = state.handleTriggerDiscovery(true);
setId('101');
await waitFor(() => expect(state.discovery()?.resource_id).toBe('101'));
const second = state.handleTriggerDiscovery(true);
if (outcome === 'success') old.resolve(saved());
else old.reject(new Error('Old guest execution paused'));
await first;
expect(state.discovery()?.resource_id).toBe('101');
expect(state.isScanning()).toBe(true);
expect(state.scanError()).toBeNull();
expect(state.scanSuccess()).toBe(false);
current.resolve(saved('101'));
await second;
expect(state.scanSuccess()).toBe(true);
expect(api.triggerDiscovery).toHaveBeenNthCalledWith(2, 'vm', 'node-agent', '101', {
force: true,
hostname: 'guest-101',
});
},
);
it('invalidates an old run even when the operator leaves and returns to the same target', async () => {
const old = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
const { state, setId } = await mount();
const first = state.handleTriggerDiscovery(true);
setId('101');
await waitFor(() => expect(state.discovery()?.resource_id).toBe('101'));
setId('100');
await waitFor(() => expect(state.discovery()?.resource_id).toBe('100'));
old.resolve({ ...saved(), service_name: 'Old run result' });
await first;
expect(state.discovery()?.service_name).toBe('Saved service 100');
expect(state.scanSuccess()).toBe(false);
});
it('does not resurrect a run after Discovery was disabled and re-enabled', async () => {
const old = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
const { state } = await mount();
const first = state.handleTriggerDiscovery(true);
syncAIRuntimeSettings({ discovery_enabled: false } as Parameters<typeof syncAIRuntimeSettings>[0]);
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(false));
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true));
old.resolve({ ...saved(), service_name: 'Disabled run result' });
await first;
expect(state.discovery()?.service_name).toBe('Saved service 100');
expect(state.scanSuccess()).toBe(false);
});
it('ignores HTTP completion after disposal without scheduling another success timer', async () => {
const old = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
const { state, dispose } = await mount();
vi.useFakeTimers();
const first = state.handleTriggerDiscovery(true);
dispose();
old.resolve(saved());
await first;
expect(state.scanSuccess()).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('does not leave a spinner running when a retained handler has no target agent', async () => {
const { state } = await mount('');
await state.handleTriggerDiscovery(true);
expect(api.triggerDiscovery).not.toHaveBeenCalled();
expect(state.scanError()).toBe('Agent identifier unavailable for discovery');
expect(state.isScanning()).toBe(false);
expect(state.scanSuccess()).toBe(false);
});
it('does not let an earlier success timer clear the success of a later explicit run', async () => {
const { state } = await mount();
vi.useFakeTimers();
await state.handleTriggerDiscovery(true);
await vi.advanceTimersByTimeAsync(1500);
await state.handleTriggerDiscovery(true);
await vi.advanceTimersByTimeAsync(600);
expect(state.scanSuccess()).toBe(true);
await vi.advanceTimersByTimeAsync(1400);
expect(state.scanSuccess()).toBe(false);
});
it.each(['failed', 'completed'] as const)(
'retains a background %s-with-error outcome and the saved evidence, not silent success',
async (status) => {
const { state } = await mount();
const before = vi.mocked(api.getDiscovery).mock.calls.length;
vi.useFakeTimers();
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status,
error: 'Guest execution paused: backup lock observed',
current_step: 'Guest execution paused',
});
await vi.advanceTimersByTimeAsync(600);
expect(state.scanError()).toBe('Guest execution paused: backup lock observed');
expect(state.scanSuccess()).toBe(false);
expect(state.discovery()?.service_name).toBe('Saved service 100');
expect(api.getDiscovery).toHaveBeenCalledTimes(before);
expect(api.triggerDiscovery).not.toHaveBeenCalled();
},
);
it('shows background progress without claiming completion of a manual HTTP request', async () => {
const http = deferred<ResourceDiscovery>();
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(http.promise);
const { state } = await mount();
eventBus.emit('ai_discovery_progress', {
resource_id: 'vm:node-agent:100',
status: 'running',
current_step: 'Collecting guest evidence',
});
expect(state.isScanning()).toBe(true);
eventBus.emit('ai_discovery_progress', { resource_id: 'vm:node-agent:100', status: 'completed' });
expect(state.isScanning()).toBe(false);
const first = state.handleTriggerDiscovery(true);
eventBus.emit('ai_discovery_progress', { resource_id: 'vm:node-agent:100', status: 'completed' });
expect(state.isScanning()).toBe(true);
expect(state.scanSuccess()).toBe(false);
http.resolve(saved());
await first;
expect(state.scanSuccess()).toBe(true);
});
it('drops a delayed background refresh when its target changed before dispatch', async () => {
const { state, setId } = await mount();
vi.useFakeTimers();
eventBus.emit('ai_discovery_progress', { resource_id: 'vm:node-agent:100', status: 'completed' });
setId('101');
await Promise.resolve();
const before = vi.mocked(api.getDiscovery).mock.calls.length;
await vi.advanceTimersByTimeAsync(600);
expect(api.getDiscovery).toHaveBeenCalledTimes(before);
expect(state.discovery()?.resource_id).toBe('101');
expect(api.triggerDiscovery).not.toHaveBeenCalled();
});
it('does not apply a delayed background read to a newer manual result on the same guest', async () => {
const refresh = deferred<ResourceDiscovery | null>();
const { state } = await mount();
vi.mocked(api.getDiscovery).mockReturnValueOnce(refresh.promise);
vi.useFakeTimers();
eventBus.emit('ai_discovery_progress', { resource_id: 'vm:node-agent:100', status: 'completed' });
await vi.advanceTimersByTimeAsync(500);
vi.mocked(api.triggerDiscovery).mockResolvedValueOnce({ ...saved(), service_name: 'New manual result' });
await state.handleTriggerDiscovery(true);
refresh.resolve({ ...saved(), service_name: 'Old background result' });
await Promise.resolve();
expect(state.discovery()?.service_name).toBe('New manual result');
expect(state.scanSuccess()).toBe(true);
});
it('does not close another guest’s notes editor when an old save finishes', async () => {
const save = deferred<void>();
vi.mocked(api.updateDiscoveryNotes).mockReturnValueOnce(save.promise);
const { state, setId } = await mount();
state.startEditingNotes();
state.setNotesText('Original guest notes');
const first = state.handleSaveNotes();
setId('101');
await waitFor(() => expect(state.discovery()?.resource_id).toBe('101'));
state.startEditingNotes();
state.setNotesText('New guest draft');
save.resolve();
await first;
expect(state.editingNotes()).toBe(true);
expect(state.notesText()).toBe('New guest draft');
expect(api.updateDiscoveryNotes).toHaveBeenCalledWith('vm', 'node-agent', '100', {
user_notes: 'Original guest notes',
});
});
});