From 2cb3f6a9043e0bab9c54c5b5ef57b3e1cb994781 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:27:13 +0100 Subject: [PATCH 1/6] Reproduce live guest discovery offered during backup deferrals Exercise the real guest drawer and Discovery run controls plus retained handler calls and in-flight completion. Saved evidence and ordinary non-affected actions stay available. Change-source: pulse-maintainer --- .../__tests__/DiscoveryRun.safety.test.tsx | 112 ++++++++++++++++++ .../GuestDrawer.discovery-safety.test.tsx | 91 ++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx new file mode 100644 index 0000000000..16ac814dfa --- /dev/null +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx @@ -0,0 +1,112 @@ +import { createSignal, Suspense } from 'solid-js'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ResourceDiscovery } from '@/types/discovery'; +import { DiscoveryTab } from '../DiscoveryTab'; +import { useDiscoveryTabState } from '../useDiscoveryTabState'; +import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import * as discoveryApi from '@/api/discovery'; + +vi.mock('@/api/discovery', async (importOriginal) => ({ + ...(await importOriginal()), + getDiscovery: vi.fn(async () => null), + getDiscoveryInfo: vi.fn(async () => ({ + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + })), + getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), + triggerDiscovery: vi.fn(async () => null), +})); + +const reason = 'Discovery paused for this guest. Saved results remain available.'; +const target = { resourceType: 'vm' as const, agentId: 'node-agent', resourceId: '100', hostname: 'guest' }; +const saved = { + id: 'vm:node-agent:100', resource_type: 'vm', resource_id: '100', target_id: 'node-agent', + hostname: 'guest', service_type: 'home-assistant', service_name: 'Saved service', category: 'app', + 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, +} as ResourceDiscovery; + +beforeEach(() => { + resetAIRuntimeState(); + syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); + vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null); + vi.mocked(discoveryApi.triggerDiscovery).mockResolvedValue(null); +}); +afterEach(() => { cleanup(); resetAIRuntimeState(); vi.clearAllMocks(); }); + +describe('manual discovery safety', () => { + it.each([ + ['loading', () => new Promise(() => undefined)], + ['absent', async () => null], + ['empty', async () => ({ ...saved, service_type: '', service_name: '', confidence: 0 })], + ['saved', async () => saved], + ] as const)('blocks all run affordances with %s discovery while retaining passive reads', async (_name, lookup) => { + vi.mocked(discoveryApi.getDiscovery).mockImplementation(lookup); + const props = { ...target, runBlockReason: reason }; + render(() => ); + await waitFor(() => expect(discoveryApi.getDiscoveryInfo).toHaveBeenCalled()); + await waitFor(() => { + const buttons = screen.getAllByRole('button', { name: /^(Run|Re-scan|Update) Discovery/ }); + for (const button of buttons) expect(button).toBeDisabled(); + }); + expect(screen.getByTestId('discovery-run-block')).toHaveTextContent(reason); + expect(discoveryApi.getDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '100'); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + }); + + it('keeps saved evidence mounted and restores only the explicit run action, not an automatic scan', async () => { + vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(saved); + const [block, setBlock] = createSignal(reason); + const props = { ...target, get runBlockReason() { return block(); }, showManualRunAction: true }; + render(() => ); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeDisabled()); + const evidence = await screen.findByText('Saved service'); + expect(evidence).toBeVisible(); + setBlock(null); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('Saved service')).toBe(evidence); + expect(screen.queryByTestId('discovery-run-block')).toBeNull(); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + }); + + it('checks the current block inside the handler even when a caller retained the old enabled verdict', async () => { + const [block, setBlock] = createSignal(null); + const props = { ...target, get runBlockReason() { return block(); } }; + let state!: ReturnType; + render(() => { state = useDiscoveryTabState(props); return state; }); + await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true)); + setBlock(reason); + await state.handleTriggerDiscovery(true); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + expect(state.isScanning()).toBe(false); + expect(state.scanSuccess()).toBe(false); + }); + + it('does not cancel or hide an in-flight scan when a new snapshot blocks further runs', async () => { + let finish!: (value: ResourceDiscovery | null) => void; + vi.mocked(discoveryApi.triggerDiscovery).mockReturnValue(new Promise((resolve) => { finish = resolve; })); + const [block, setBlock] = createSignal(null); + const props = { ...target, get runBlockReason() { return block(); } }; + let state!: ReturnType; + render(() => { state = useDiscoveryTabState(props); return state; }); + await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true)); + const original = state.handleTriggerDiscovery(true); + expect(state.isScanning()).toBe(true); + setBlock(reason); + await state.handleTriggerDiscovery(true); + expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1); + expect(state.isScanning()).toBe(true); + finish(saved); + await original; + expect(state.isScanning()).toBe(false); + expect(state.discovery()).toBe(saved); + expect(state.scanSuccess()).toBe(true); + setBlock(null); + expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx new file mode 100644 index 0000000000..23e1b84f8e --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx @@ -0,0 +1,91 @@ +import { createSignal, Suspense } from 'solid-js'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkloadGuest } from '@/types/workloads'; +import { GuestDrawer } from '../GuestDrawer'; +import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals'; +import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import * as discoveryApi from '@/api/discovery'; + +vi.mock('@/api/discovery', async (importOriginal) => ({ + ...(await importOriginal()), + getDiscovery: vi.fn(async () => null), + getDiscoveryInfo: vi.fn(async () => ({ + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], command_categories: [], + })), + getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), + triggerDiscovery: vi.fn(async () => null), +})); +vi.mock('@/api/charts', async (importOriginal) => ({ + ...(await importOriginal()), + ChartsAPI: { getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })) }, +})); +vi.mock('@/stores/license', () => ({ isRangeLocked: () => false, loadRuntimeCapabilities: vi.fn(), maxHistoryDays: () => 90 })); + +const guest = (patch: Partial = {}): WorkloadGuest => ({ + id: 'inst:node:100', instance: 'inst', node: 'node', vmid: 100, name: 'Backup guest', type: 'qemu', + status: 'running', cpu: 0.25, cpus: 2, memory: { total: 1024, used: 512, free: 512, usage: 50 }, + disk: { total: 1024, used: 512, free: 512, usage: 50 }, + networkIn: 1, networkOut: 2, diskRead: 3, diskWrite: 4, uptime: 100, template: false, + lastBackup: 0, tags: [], lock: '', lastSeen: '2026-10-04T12:00:00Z', guestAgentStatus: 'available', + discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, ...patch, +} as WorkloadGuest); + +beforeEach(() => { + resetAIRuntimeState(); + syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); + resetCreateNonSuspendingQueryCacheForTest(); +}); +afterEach(() => { cleanup(); resetAIRuntimeState(); vi.clearAllMocks(); }); + +describe('guest drawer and real Discovery safety wiring', () => { + it.each([ + ...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]), + ])('does not offer a live scan for %s, even with an assigned node agent and available metadata', async (reason) => { + render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeDisabled()); + fireEvent.click(button); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + expect(screen.getByTestId('guest-read-precaution')).toBeVisible(); + }); + + it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])('guards the native operation signal %j without fabricating a disk read reason', async (patch) => { + render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled()); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + }); + + it('updates the existing same-guest button without remounting the tab or automatically scanning', async () => { + const [value, setValue] = createSignal(guest({ lock: 'backup' })); + render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeDisabled()); + setValue(guest()); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByRole('button', { name: 'Run Discovery' })).toBe(button); + expect(screen.getByRole('tab', { name: 'Discovery' })).toHaveAttribute('aria-selected', 'true'); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + setValue(guest({ diskStatusReason: 'prev-agent-timeout' })); + await waitFor(() => expect(button).toBeDisabled()); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + }); + + it.each([ + { type: 'qemu' }, + { type: 'lxc', lock: 'backup', diskStatusReason: 'prev-agent-timeout', discoveryTarget: { resourceType: 'system-container', agentId: 'node-agent', resourceId: '100' } }, + { type: 'vm', platformType: 'vmware', guestAgentStatus: '', lock: '', discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' } }, + ] as const)('preserves ordinary manual scans outside the Proxmox guest safety condition %j', async (patch) => { + render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + }); +}); From ea6a52d43c8500f8ca087800d810ae34bfc07495 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:42:30 +0100 Subject: [PATCH 2/6] Pause manual guest discovery during backup read deferrals Guard every run control and the retained handler with the current guest precaution. Keep saved discovery readable, same-guest selection stable and already-dispatched completion visible; clearing a pause neither proves thaw nor starts a scan. Desktop and phone Chromium/WebKit rendering verified with synthetic APIs. Contract-Neutral: Existing drawer-only manual discovery safety gating; no canonical resource, API, identity, permission or polling contract delta. Change-source: pulse-maintainer --- .../browser-tests/guest-discovery-safety.cjs | 329 ++++++++++++++++++ .../browser-tests/guest-discovery-safety.html | 12 + .../browser-tests/guest-discovery-safety.tsx | 126 +++++++ .../430ebb1acb72552b.json | 67 ++++ .../src/components/Discovery/DiscoveryTab.tsx | 24 +- .../__tests__/DiscoveryRun.safety.test.tsx | 125 +++++-- .../Discovery/useDiscoveryTabState.ts | 14 +- .../src/components/Workloads/GuestDrawer.tsx | 5 + .../GuestDrawer.discovery-safety.test.tsx | 150 +++++--- 9 files changed, 777 insertions(+), 75 deletions(-) create mode 100644 frontend-modern/browser-tests/guest-discovery-safety.cjs create mode 100644 frontend-modern/browser-tests/guest-discovery-safety.html create mode 100644 frontend-modern/browser-tests/guest-discovery-safety.tsx create mode 100644 frontend-modern/browser-verification/430ebb1acb72552b.json diff --git a/frontend-modern/browser-tests/guest-discovery-safety.cjs b/frontend-modern/browser-tests/guest-discovery-safety.cjs new file mode 100644 index 0000000000..8629e6cc4e --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.cjs @@ -0,0 +1,329 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/discovery-safety-proof/browser'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestDrawer.tsx', + 'frontend-modern/src/components/Discovery/DiscoveryTab.tsx', + 'frontend-modern/src/components/Discovery/useDiscoveryTabState.ts', +]; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const initial = { + state: 'deferred', + reason: 'prev-vm-locked', + lock: 'backup', + backup: true, + version: '6.4.5', + assignment: true, + diskUsed: 5 * 1024 ** 3, +}; +const healthy = { ...initial, state: 'available', reason: '', lock: '', backup: false }; +const saved = { + id: 'vm:fixture-node-agent:101', + resource_type: 'vm', + resource_id: '101', + target_id: 'fixture-node-agent', + hostname: 'backup-guest', + service_type: 'home-assistant', + service_name: 'Saved Home Assistant', + service_version: 'test', + category: 'home_automation', + cli_access: '', + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0.9, + ai_reasoning: 'Synthetic saved evidence', + discovered_at: '2026-10-03T12:00:00Z', + updated_at: '2026-10-03T12:00:00Z', + discovery_engine_version: 1, +}; +(async () => { + fs.mkdirSync(output, { recursive: true }); + const report = { + runtime_sha256: Object.fromEntries(runtime.map((p) => [p, hash(path.join('/workspace', p))])), + playwright: require('playwright/package.json').version, + groups: [], + screenshots: [], + cleanup: {}, + }; + assert.equal( + report.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5297, strictPort: true, watch: null }, + }); + const origin = 'http://127.0.0.1:5297'; + let browser, + phase = 'server'; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop-light', chromium, 1365, false], + ['chromium-desktop-dark', chromium, 1365, true], + ['webkit-phone-dark', webkit, 390, true], + ['webkit-narrow-light', webkit, 320, false], + ]) { + phase = name; + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 500 ? 844 : 900 }, + isMobile: width < 500, + hasTouch: width < 500, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(60000); + const errors = [], + requests = [], + offOrigin = [], + checks = []; + report.groups.push({ + name, + width, + dark, + browser: browser.version(), + checks, + requests, + errors, + offOrigin, + }); + await page.routeWebSocket('**/ws*', () => {}); + let hold = false, + release; + page.on('pageerror', (e) => errors.push(e.message)); + await page.addInitScript((dark) => { + const apply = () => document.documentElement.classList.toggle('dark', dark); + if (document.documentElement) apply(); + else document.addEventListener('DOMContentLoaded', apply, { once: true }); + }, dark); + await page.route('**/*', async (route) => { + const req = route.request(), + url = new URL(req.url()); + if (url.origin !== origin) { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ + path: url.pathname, + query: url.search, + method: req.method(), + body: req.postData(), + }); + if (url.pathname === '/api/discovery/vm/fixture-node-agent/101') { + if (req.method() === 'POST') { + if (hold) + await new Promise((resolve) => { + release = resolve; + }); + return route.fulfill({ json: saved }); + } + return route.fulfill({ json: saved }); + } + assert.equal(req.method(), 'GET'); + if (url.pathname.startsWith('/api/discovery/info/')) + return route.fulfill({ + json: { + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + }, + }); + if (url.pathname === '/api/ai/agents') + return route.fulfill({ + json: { + count: 1, + agents: [ + { + agent_id: 'fixture-node-agent', + hostname: 'pve-a', + version: '6.4.5', + platform: 'linux', + }, + ], + }, + }); + if (url.pathname.includes('metrics-store/history')) + return route.fulfill({ + json: { + resourceType: 'vm', + resourceId: url.searchParams.get('resourceId'), + range: '24h', + start: Date.UTC(2026, 9, 3, 12), + end: Date.UTC(2026, 9, 4, 12), + metrics: {}, + source: 'store', + }, + }); + if (url.pathname === '/api/resources') + return route.fulfill({ json: { data: [], total: 0, page: 1, limit: 500 } }); + if (url.pathname.includes('license')) + return route.fulfill({ json: { capabilities: [], limits: [], max_history_days: 90 } }); + if (url.pathname.includes('metadata')) return route.fulfill({ json: {} }); + return route.fulfill({ + json: { success: true, data: [], alerts: [], anomalies: [], config: null }, + }); + }); + await page.goto(`${origin}/browser-tests/guest-discovery-safety.html`, { + waitUntil: 'domcontentloaded', + }); + await page.waitForFunction(() => Boolean(window.__guestDiscoverySafety)); + const row = page.locator('[data-guest-id]').first(); + if (width > 500) { + await row.getByRole('button', { name: 'Expand backup-guest', exact: true }).focus(); + await page.keyboard.press('Enter'); + } else await row.tap(); + await page.getByRole('tab', { name: 'Discovery', exact: true }).click(); + const run = page.getByRole('button', { name: 'Run Discovery', exact: true }); + await run.waitFor(); + const update = async (value) => + page.evaluate((v) => window.__guestDiscoverySafety.update(v), value); + const postCount = () => requests.filter((r) => r.method === 'POST').length; + const block = page.getByTestId('discovery-run-block'); + const capture = async (state) => { + const filename = `${name}-${state}.png`, + file = path.join(output, filename); + await run.scrollIntoViewIfNeeded(); + await page.screenshot({ path: file, fullPage: true }); + report.screenshots.push({ filename, sha256: hash(file) }); + }; + const layout = async () => { + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + for (const loc of [block, page.getByTestId('guest-read-precaution')]) { + const box = await loc.boundingBox(); + assert(box && box.x >= 0 && box.x + box.width <= width + 1); + assert(await loc.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)); + } + }; + await page.getByText('Saved Home Assistant', { exact: true }).first().waitFor(); + assert(await run.isDisabled()); + assert.equal(postCount(), 0); + assert((await block.innerText()).includes('does not prove thaw')); + assert(await run.getAttribute('aria-describedby')); + assert.equal(await block.getAttribute('id'), await run.getAttribute('aria-describedby')); + await layout(); + await capture('locked-saved'); + checks.push( + 'Native canonical projection, row and real drawer/Discovery: assigned node agent, saved results readable, backup blocks live run with associated wrapping reason', + ); + await page.evaluate(() => { + window.__originalSafetyButton = document.querySelector( + '[aria-describedby^="discovery-run-block-"]', + ); + }); + for (const reason of [ + 'vm-locked', + 'lock-unverified', + 'agent-busy', + 'agent-cooldown', + 'agent-response-incomplete', + 'agent-capacity', + 'invalid-guest-key', + 'agent-timeout', + ]) { + for (const prefix of ['', 'prev-']) { + await update({ ...healthy, reason: prefix + reason }); + assert(await run.isDisabled()); + assert.equal(postCount(), 0); + assert( + await page.evaluate( + () => + document.querySelector('[aria-describedby^="discovery-run-block-"]') === + window.__originalSafetyButton, + ), + ); + } + } + await update({ ...healthy, reason: 'prev-agent-timeout' }); + await capture('uncertain-saved'); + await page.getByRole('tab', { name: 'History', exact: true }).click(); + assert(await page.getByTestId('guest-read-precaution').isVisible()); + await page.getByRole('tab', { name: 'Discovery', exact: true }).click(); + assert(await run.isDisabled()); + checks.push( + 'All eight read deferrals, retained variants and same-ID tab/snapshot updates block without remount, POST or automatic rescan', + ); + await update(healthy); + assert(await run.isEnabled()); + assert.equal(await block.count(), 0); + assert.equal(postCount(), 0); + await capture('ready-manual'); + await run.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => document.body.textContent.includes('Discovery complete!')); + assert.equal(postCount(), 1); + const first = requests.find((r) => r.method === 'POST'); + assert.deepEqual(JSON.parse(first.body), { force: true, hostname: 'backup-guest' }); + checks.push( + 'Explicit healthy keyboard run sends exactly the original target/body once; clearing safety pause alone sends nothing', + ); + await page.waitForFunction(() => !document.body.textContent.includes('Discovery complete!')); + hold = true; + const secondPost = page.waitForRequest( + (req) => + req.method() === 'POST' && + new URL(req.url()).pathname === '/api/discovery/vm/fixture-node-agent/101', + ); + if (width < 500) await run.tap(); + else await run.click(); + await secondPost; + await page.waitForFunction(() => document.body.textContent.includes('Scanning...')); + assert.equal(postCount(), 2); + await update({ ...initial, reason: 'agent-timeout', lock: '', backup: false }); + assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled()); + assert(await block.isVisible()); + assert((await page.locator('body').innerText()).includes('Scanning...')); + assert.equal(postCount(), 2); + assert(release); + release(); + await page.waitForFunction(() => document.body.textContent.includes('Discovery complete!')); + assert(await run.isDisabled()); + assert.equal(postCount(), 2); + await capture('inflight-completed-blocked'); + checks.push( + 'A newly blocked snapshot neither hides nor cancels dispatched work; original completion is shown and no further run is sent', + ); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + await browser.close(); + browser = null; + } + report.result = 'passed'; + report.verified_at = new Date().toISOString(); + } catch (error) { + report.result = 'failed'; + report.phase = phase; + report.error = String(error.stack || error); + throw error; + } finally { + if (browser) await browser.close(); + report.cleanup.browser_closed = true; + await server.close(); + report.cleanup.server_closed = true; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2) + '\n'); + } +})().catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/guest-discovery-safety.html b/frontend-modern/browser-tests/guest-discovery-safety.html new file mode 100644 index 0000000000..74a7217db4 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.html @@ -0,0 +1,12 @@ + + + + + + Guest Discovery safety verification + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-discovery-safety.tsx b/frontend-modern/browser-tests/guest-discovery-safety.tsx new file mode 100644 index 0000000000..dfeb8e8018 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.tsx @@ -0,0 +1,126 @@ +// Production canonical resource projection, row, drawer, router and Docs. +// Synthetic observations only; no native QGA, diagnostic or backup operation. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { useWorkloads } from '../src/hooks/useWorkloads'; +import { mergeCanonicalResourceSnapshot } from '../src/utils/resourceStateAdapters'; +import { GuestRow } from '../src/components/Workloads/GuestRow'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import Docs from '../src/pages/Docs'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: true }); +interface Observation { + state?: string; + reason?: string; + lock?: string; + backup?: boolean; + version?: string; + assignment?: boolean; + diskUsed?: number; +} +const initial: Observation = { + state: 'deferred', + reason: 'prev-vm-locked', + lock: 'backup', + backup: true, + version: '6.4.5', + assignment: true, + diskUsed: 5 * 1024 ** 3, +}; +const nativeResource = (value: Observation): Resource => ({ + id: 'fixture-pve:pve-a:101', + type: 'vm', + name: 'backup-guest', + displayName: 'backup-guest', + platformType: 'proxmox-pve', + platformScopes: ['proxmox-pve'], + sourceType: 'api', + status: 'online', + sources: ['proxmox', 'agent'], + lastSeen: Date.UTC(2026, 9, 4, 14), + cpu: { current: 10 }, + memory: { current: 25, used: 1024 ** 3 / 4, total: 1024 ** 3 }, + disk: { + current: ((value.diskUsed ?? 0) / (10 * 1024 ** 3)) * 100, + used: value.diskUsed, + total: 10 * 1024 ** 3, + }, + proxmox: { + instance: 'fixture-pve', + nodeName: 'pve-a', + vmid: 101, + runtimeStatus: 'running', + template: false, + guestAgentStatus: value.state, + diskStatusReason: value.reason, + lock: value.lock, + guestAgentExpected: true, + backupInProgress: value.backup ?? false, + lastBackup: '2026-10-04T06:00:00Z', + disks: [], + }, + agent: { + agentId: 'fixture-pulse-agent', + agentVersion: value.version || '', + hostname: 'backup-guest', + osName: 'Linux', + }, + discoveryTarget: value.assignment + ? { resourceType: 'vm', agentId: 'fixture-node-agent', resourceId: '101' } + : undefined, +}); +function Fixture() { + const [observation, setObservation] = createSignal(initial); + const [snapshot, setSnapshot] = createSignal([nativeResource(initial)]); + const state = useWorkloads(() => true, { resourceSnapshot: snapshot }); + const guest = () => state.workloads()[0]!; + const [expanded, setExpanded] = createSignal(false); + (window as any).__guestDiscoverySafety = { + update: (next: Observation) => { + setObservation(next); + setSnapshot((previous) => mergeCanonicalResourceSnapshot([nativeResource(next)], previous)); + }, + observation, + identity: () => guest().id, + }; + return ( +
+

Guest Discovery safety evidence

+

Synthetic observations. No guest check is sent.

+ + + + setExpanded(!expanded())} + workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'} + /> + +
+ +
+ setExpanded(false)} /> +
+
+
+
+ ); +} +render( + () => ( + document.documentElement.classList.contains('dark')}> + + + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/430ebb1acb72552b.json b/frontend-modern/browser-verification/430ebb1acb72552b.json new file mode 100644 index 0000000000..ce1175ad3d --- /dev/null +++ b/frontend-modern/browser-verification/430ebb1acb72552b.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "2cb3f6a9043e0bab9c54c5b5ef57b3e1cb994781", + "changed_paths": [ + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts", + "frontend-modern/src/components/Workloads/GuestDrawer.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "d8ba9c14cbfac0756f8df461bea07c40a39ef56ba907301b71e92286e410facc", + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "01e2d687067df69d4f3eb6ea74742c9ce478f278976ee87c96c3567ff1b82896", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "19388b7e163d5fff79416f4bcb9c47ba0718cede9a56dbdf331065ab09cc00ee" + }, + "routes": [ + "/browser-tests/guest-discovery-safety.html: production canonical resource projection, Workloads row, GuestDrawer and DiscoveryTab with synthetic APIs" + ], + "states": [ + "Desktop light/dark and 390px/320px WebKit phone: locked guest and saved results", + "Every declared deferral and retained variant; same-ID/tab changes", + "Healthy manual action; uncertain read; in-flight scan completion while further runs are blocked" + ], + "interactions": [ + "Keyboard expand/tab/explicit run and touch expand/run; accessible disabled reason; wrapping warning/saved service inspection", + "No POST on blocked/unlock/snapshot/tab changes; exactly one original-body POST per explicit enabled run", + "No remount, cancellation or false in-flight-stop; original completion remains visible and additional run is blocked" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "verified_at": "2026-10-04T15:40:18.829Z", + "script": "frontend-modern/browser-tests/guest-discovery-safety.cjs", + "browsers": [ + { + "group": "chromium-desktop-light", + "version": "141.0.7390.37" + }, + { + "group": "chromium-desktop-dark", + "version": "141.0.7390.37" + }, + { + "group": "webkit-phone-dark", + "version": "26.0" + }, + { + "group": "webkit-narrow-light", + "version": "26.0" + } + ], + "limitations": [ + "Synthetic backend responses, not a native guest/backup or all-path backend admission proof.", + "Vite-transformed production components; production build is separately checked in the source-proof VM.", + "Independent thaw/covered-filesystem writes remain necessary; an unlocked snapshot does not prove them." + ] +} diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index 6de5668272..169fd12981 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -1,4 +1,4 @@ -import { Component, For, Show, createMemo } from 'solid-js'; +import { Component, For, Show, createMemo, createUniqueId } from 'solid-js'; import ExternalLinkIcon from 'lucide-solid/icons/external-link'; import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert'; import type { ResourceType } from '../../types/discovery'; @@ -33,6 +33,7 @@ import { import { ButtonLink, CopyValueButton } from '@/components/shared/Button'; import { CopyableCodeRow } from '@/components/shared/CopyableCodeRow'; import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame'; +import { InlineNotice } from '@/components/shared/InlineNotice'; import { useDiscoveryTabState } from './useDiscoveryTabState'; import { orderFactsByActionability } from './factOrdering'; import { deriveCliCommand } from './cliCommand'; @@ -54,6 +55,8 @@ interface DiscoveryTabProps { commandsEnabled?: boolean; /** Show the primary run action at the top of embedded drawer contexts. */ showManualRunAction?: boolean; + /** Safety pause for live manual scans, independent of command permissions. */ + runBlockReason?: string | null; } export const DiscoveryTab: Component = (props) => { @@ -75,6 +78,7 @@ export const DiscoveryTab: Component = (props) => { liveElapsedSeconds, notesText, mutateDiscovery, + runBlockReason, saveError, scanError, scanProgress, @@ -90,6 +94,7 @@ export const DiscoveryTab: Component = (props) => { startEditingNotes, validDiscovery, } = useDiscoveryTabState(props); + const runBlockReasonId = `discovery-run-block-${createUniqueId()}`; const suggestedURLReasonText = createMemo(() => { const current = discovery(); @@ -225,6 +230,19 @@ export const DiscoveryTab: Component = (props) => { + + {(reason) => ( + + {reason()} + + )} + +
@@ -236,6 +254,7 @@ export const DiscoveryTab: Component = (props) => { type="button" onClick={() => handleTriggerDiscovery(true)} disabled={isScanning() || !canTriggerDiscovery()} + aria-describedby={runBlockReason() ? runBlockReasonId : undefined} class="inline-flex min-h-9 items-center justify-center gap-1.5 rounded-sm border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50" > = (props) => {
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx index 23e1b84f8e..f8c98610a1 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx @@ -13,56 +13,104 @@ vi.mock('@/api/discovery', async (importOriginal) => ({ getDiscovery: vi.fn(async () => null), getDiscoveryInfo: vi.fn(async () => ({ ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, - commands: [], command_categories: [], + commands: [], + command_categories: [], })), getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), triggerDiscovery: vi.fn(async () => null), })); vi.mock('@/api/charts', async (importOriginal) => ({ ...(await importOriginal()), - ChartsAPI: { getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })) }, + ChartsAPI: { + getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })), + }, +})); +vi.mock('@/stores/license', () => ({ + isRangeLocked: () => false, + loadRuntimeCapabilities: vi.fn(), + maxHistoryDays: () => 90, })); -vi.mock('@/stores/license', () => ({ isRangeLocked: () => false, loadRuntimeCapabilities: vi.fn(), maxHistoryDays: () => 90 })); -const guest = (patch: Partial = {}): WorkloadGuest => ({ - id: 'inst:node:100', instance: 'inst', node: 'node', vmid: 100, name: 'Backup guest', type: 'qemu', - status: 'running', cpu: 0.25, cpus: 2, memory: { total: 1024, used: 512, free: 512, usage: 50 }, - disk: { total: 1024, used: 512, free: 512, usage: 50 }, - networkIn: 1, networkOut: 2, diskRead: 3, diskWrite: 4, uptime: 100, template: false, - lastBackup: 0, tags: [], lock: '', lastSeen: '2026-10-04T12:00:00Z', guestAgentStatus: 'available', - discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, ...patch, -} as WorkloadGuest); +const guest = (patch: Partial = {}): WorkloadGuest => + ({ + id: 'inst:node:100', + instance: 'inst', + node: 'node', + vmid: 100, + name: 'Backup guest', + type: 'qemu', + status: 'running', + cpu: 0.25, + cpus: 2, + memory: { total: 1024, used: 512, free: 512, usage: 50 }, + disk: { total: 1024, used: 512, free: 512, usage: 50 }, + networkIn: 1, + networkOut: 2, + diskRead: 3, + diskWrite: 4, + uptime: 100, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: '2026-10-04T12:00:00Z', + guestAgentStatus: 'available', + discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, + ...patch, + }) as WorkloadGuest; beforeEach(() => { resetAIRuntimeState(); syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); resetCreateNonSuspendingQueryCacheForTest(); }); -afterEach(() => { cleanup(); resetAIRuntimeState(); vi.clearAllMocks(); }); +afterEach(() => { + cleanup(); + resetAIRuntimeState(); + vi.clearAllMocks(); +}); describe('guest drawer and real Discovery safety wiring', () => { - it.each([ - ...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]), - ])('does not offer a live scan for %s, even with an assigned node agent and available metadata', async (reason) => { - render(() => ); - fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); - const button = await screen.findByRole('button', { name: 'Run Discovery' }); - await waitFor(() => expect(button).toBeDisabled()); - fireEvent.click(button); - expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); - expect(screen.getByTestId('guest-read-precaution')).toBeVisible(); - }); + it.each([...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`])])( + 'does not offer a live scan for %s, even with an assigned node agent and available metadata', + async (reason) => { + render(() => ( + + + + )); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeDisabled()); + fireEvent.click(button); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + expect(screen.getByTestId('guest-read-precaution')).toBeVisible(); + }, + ); - it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])('guards the native operation signal %j without fabricating a disk read reason', async (patch) => { - render(() => ); - fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled()); - expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); - }); + it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])( + 'guards the native operation signal %j without fabricating a disk read reason', + async (patch) => { + render(() => ( + + + + )); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled(), + ); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + }, + ); it('updates the existing same-guest button without remounting the tab or automatically scanning', async () => { const [value, setValue] = createSignal(guest({ lock: 'backup' })); - render(() => ); + render(() => ( + + + + )); fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); const button = await screen.findByRole('button', { name: 'Run Discovery' }); await waitFor(() => expect(button).toBeDisabled()); @@ -78,14 +126,36 @@ describe('guest drawer and real Discovery safety wiring', () => { it.each([ { type: 'qemu' }, - { type: 'lxc', lock: 'backup', diskStatusReason: 'prev-agent-timeout', discoveryTarget: { resourceType: 'system-container', agentId: 'node-agent', resourceId: '100' } }, - { type: 'vm', platformType: 'vmware', guestAgentStatus: '', lock: '', discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' } }, - ] as const)('preserves ordinary manual scans outside the Proxmox guest safety condition %j', async (patch) => { - render(() => ); - fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); - const button = await screen.findByRole('button', { name: 'Run Discovery' }); - await waitFor(() => expect(button).toBeEnabled()); - fireEvent.click(button); - await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); - }); + { + type: 'lxc', + lock: 'backup', + diskStatusReason: 'prev-agent-timeout', + discoveryTarget: { + resourceType: 'system-container', + agentId: 'node-agent', + resourceId: '100', + }, + }, + { + type: 'vm', + platformType: 'vmware', + guestAgentStatus: '', + lock: '', + discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, + }, + ] as const)( + 'preserves ordinary manual scans outside the Proxmox guest safety condition %j', + async (patch) => { + render(() => ( + + + + )); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + }, + ); }); From 773fd82073b99c82421f2a60f072739fadb082aa Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:48:39 +0100 Subject: [PATCH 3/6] Complete typed saved-discovery safety fixtures Keep the browser-verified runtime unchanged while supplying the actual required discovery fields and explicit async fixture return types. The first connected proof passed all 1,932 tests but failed TypeScript; retain it and validate this exact correction. Change-source: pulse-maintainer --- .../__tests__/DiscoveryRun.safety.test.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx index 4fc580c161..258b615192 100644 --- a/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx @@ -26,7 +26,7 @@ const target = { resourceId: '100', hostname: 'guest', }; -const saved = { +const saved: ResourceDiscovery = { id: 'vm:node-agent:100', resource_type: 'vm', resource_id: '100', @@ -35,6 +35,11 @@ const saved = { service_type: 'home-assistant', service_name: 'Saved service', category: 'home_automation', + service_version: 'fixture', + cli_access: '', + user_secrets: {}, + ai_reasoning: 'Synthetic saved evidence', + scan_duration: 0, facts: [], config_paths: [], data_paths: [], @@ -44,7 +49,7 @@ const saved = { discovered_at: '2026-10-03T12:00:00Z', updated_at: '2026-10-03T12:00:00Z', confidence: 0.9, -} as ResourceDiscovery; +}; beforeEach(() => { resetAIRuntimeState(); @@ -60,10 +65,18 @@ afterEach(() => { describe('manual discovery safety', () => { it.each([ - ['loading', () => new Promise(() => undefined)], - ['absent', async () => null], - ['empty', async () => ({ ...saved, service_type: '', service_name: '', confidence: 0 })], - ['saved', async () => saved], + ['loading', (): Promise => new Promise(() => undefined)], + ['absent', async (): Promise => null], + [ + 'empty', + async (): Promise => ({ + ...saved, + service_type: '', + service_name: '', + confidence: 0, + }), + ], + ['saved', async (): Promise => saved], ] as const)( 'blocks all run affordances with %s discovery while retaining passive reads', async (_name, lookup) => { From bc3f4d81d14873caae6f769dfa670d16fa13d518 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:16:17 +0100 Subject: [PATCH 4/6] 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 --- .../__tests__/DiscoveryRun.lifecycle.test.tsx | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.lifecycle.test.tsx diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.lifecycle.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.lifecycle.test.tsx new file mode 100644 index 0000000000..ab6a4c1014 --- /dev/null +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.lifecycle.test.tsx @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((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; + const view = render(() => { + state = useDiscoveryTabState({ + resourceType: 'vm', + get agentId() { + return agent(); + }, + get resourceId() { + return id(); + }, + get hostname() { + return `guest-${id()}`; + }, + }); + return Discovery lifecycle; + }); + 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[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(); + const current = deferred(); + 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(); + 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(); + vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise); + const { state } = await mount(); + const first = state.handleTriggerDiscovery(true); + syncAIRuntimeSettings({ discovery_enabled: false } as Parameters[0]); + await waitFor(() => expect(state.canTriggerDiscovery()).toBe(false)); + syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[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(); + 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(); + 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(); + 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(); + 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', + }); + }); +}); From 817834440b3d83056c205eb92920fc4e7c5f28e8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:35:54 +0100 Subject: [PATCH 5/6] Keep Discovery outcomes attached to their guest and run Preserve dispatched work across a same-guest backup precaution, but reject late HTTP, notes, timer and background refresh results after target, feature or run ownership changes. Keep background safety failures visible with saved evidence, distinguish scanner progress from saved HTTP completion, and make error dismissal accessible. The recovered drawer pause and these connected outcomes have current browser proof. Contract-Neutral: Frontend-only behavioural repair of existing precaution, target/run ownership and error presentation. No resource schema, API, route, feature, command admission, permissions or collection contract changes. Change-source: pulse-maintainer --- .../discovery-outcome-ownership.cjs | 301 ++++++++++++++++++ .../discovery-outcome-ownership.html | 12 + .../discovery-outcome-ownership.tsx | 54 ++++ .../guest-discovery-completion.cjs | 19 ++ .../c45de66a92dcfc91.json | 79 +++++ .../src/components/Discovery/DiscoveryTab.tsx | 11 +- .../__tests__/DiscoveryRun.lifecycle.test.tsx | 75 ++++- .../Discovery/useDiscoveryTabState.ts | 184 +++++++---- 8 files changed, 670 insertions(+), 65 deletions(-) create mode 100644 frontend-modern/browser-tests/discovery-outcome-ownership.cjs create mode 100644 frontend-modern/browser-tests/discovery-outcome-ownership.html create mode 100644 frontend-modern/browser-tests/discovery-outcome-ownership.tsx create mode 100644 frontend-modern/browser-tests/guest-discovery-completion.cjs create mode 100644 frontend-modern/browser-verification/c45de66a92dcfc91.json diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.cjs b/frontend-modern/browser-tests/discovery-outcome-ownership.cjs new file mode 100644 index 0000000000..3d7238fbab --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.cjs @@ -0,0 +1,301 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/discovery-ownership-proof/browser'; +const origin = 'http://127.0.0.1:5333'; +const runtime = [ + 'frontend-modern/src/components/Discovery/DiscoveryTab.tsx', + 'frontend-modern/src/components/Discovery/useDiscoveryTabState.ts', + 'frontend-modern/src/components/Workloads/GuestDrawer.tsx', +]; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const saved = (id, service = `Saved Home Assistant ${id}`) => ({ + id: `vm:fixture-node-agent:${id}`, + resource_type: 'vm', + resource_id: id, + target_id: 'fixture-node-agent', + hostname: `guest-${id}`, + service_type: 'home-assistant', + service_name: service, + service_version: 'test', + category: 'home_automation', + cli_access: '', + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0.9, + ai_reasoning: 'Synthetic saved evidence', + discovered_at: '2026-10-03T12:00:00Z', + updated_at: '2026-10-03T12:00:00Z', + discovery_engine_version: 1, +}); +(async () => { + fs.mkdirSync(output, { recursive: true }); + const report = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + runtime_sha256: Object.fromEntries(runtime.map((p) => [p, hash('/workspace/' + p)])), + groups: [], + screenshots: [], + cleanup: {}, + limits: + 'Production DiscoveryTab/state/CSS, synthetic API and production event-bus progress. No native QGA, automatic collector, original freeze cause, thaw, covered filesystem writes, installed, physical-phone or release acceptance.', + }; + assert.equal( + report.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: output + '/cache', + server: { host: '127.0.0.1', port: 5333, strictPort: true, watch: null }, + }); + let browser, + phase = 'server'; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop-light', chromium, 1365, false], + ['desktop-dark', chromium, 1365, true], + ['phone-dark', webkit, 390, true], + ['narrow-light', webkit, 320, false], + ]) { + phase = name; + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 500 ? 844 : 900 }, + isMobile: width < 500, + hasTouch: width < 500, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(60000); + const group = { + name, + width, + dark, + browser: browser.version(), + checks: [], + requests: [], + errors: [], + offOrigin: [], + }; + report.groups.push(group); + page.on('pageerror', (error) => group.errors.push(error.message)); + await page.routeWebSocket('**/ws*', () => {}); + await page.addInitScript((dark) => { + const apply = () => document.documentElement.classList.toggle('dark', dark); + if (document.documentElement) apply(); + else document.addEventListener('DOMContentLoaded', apply, { once: true }); + }, dark); + let fail = false; + const releases = new Map(); + const arrivals = new Map(); + const stored = new Map(); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== origin) { + group.offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + group.requests.push({ + path: url.pathname, + method: request.method(), + body: request.postData(), + }); + const target = url.pathname.match(/^\/api\/discovery\/vm\/fixture-node-agent\/(101|102)$/); + if (target) { + const id = target[1]; + if (request.method() === 'GET') + return route.fulfill({ json: stored.get(id) || saved(id) }); + assert.equal(request.method(), 'POST'); + if (fail) + return route.fulfill({ + status: 409, + json: { error: 'Guest execution paused: backup lock observed' }, + }); + await new Promise((resolve) => { + releases.set(id, resolve); + arrivals.get(id)?.(); + }); + const result = saved(id, id === '101' ? 'Old guest result' : 'Current guest result'); + stored.set(id, result); + return route.fulfill({ json: result }); + } + assert.equal(request.method(), 'GET'); + const responses = { + '/api/discovery/info/vm': { + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + }, + '/api/ai/agents': { + count: 1, + agents: [{ agent_id: 'fixture-node-agent', hostname: 'pve1' }], + }, + '/api/ai/settings': { discovery_enabled: true, enabled: false }, + '/api/license/runtime-capabilities': { + capabilities: [], + limits: [], + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + }, + }; + return route.fulfill({ json: responses[url.pathname] || {} }); + }); + const capture = async (suffix) => { + await page.screenshot({ path: `${output}/${name}-${suffix}.png`, fullPage: true }); + report.screenshots.push(`${name}-${suffix}.png`); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + }; + const emit = (progress) => + page.evaluate((progress) => window.discoveryOwnership.progress(progress), progress); + const run = page.getByRole('button', { name: 'Run Discovery', exact: true }); + const pressRun = async () => { + if (width >= 500) { + await run.focus(); + await page.keyboard.press('Enter'); + } else await run.tap(); + }; + await page.goto(origin + '/browser-tests/discovery-outcome-ownership.html'); + await page.getByText('Saved Home Assistant 101', { exact: true }).waitFor(); + await run.waitFor(); + await page.waitForFunction( + () => + !Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent.trim() === 'Run Discovery', + ).disabled, + ); + const before = group.requests.length; + await emit({ + resource_id: 'vm:fixture-node-agent:101', + status: 'running', + current_step: 'Collecting guest evidence', + percent_complete: 20, + }); + await page.getByText('Collecting guest evidence', { exact: true }).waitFor(); + await emit({ + resource_id: 'vm:fixture-node-agent:101', + status: 'completed', + error: 'Guest execution paused: backup lock observed', + current_step: 'Guest execution paused', + percent_complete: 20, + }); + const alert = page.getByRole('alert'); + await alert.waitFor(); + await page.waitForTimeout(650); + assert((await alert.innerText()).includes('Guest execution paused: backup lock observed')); + assert.equal(group.requests.length, before); + assert(await page.getByText('Saved Home Assistant 101', { exact: true }).isVisible()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + await capture('retained-background-pause'); + group.checks.push( + 'Background completed-with-error remains an accessible failure with saved evidence and no retry or refresh', + ); + const dismiss = page.getByRole('button', { name: 'Dismiss Discovery error' }); + await dismiss.focus(); + await page.keyboard.press('Enter'); + assert.equal(await alert.count(), 0); + group.checks.push('The error dismiss action has a keyboard-accessible name'); + const firstPost = new Promise((resolve) => arrivals.set('101', resolve)); + await pressRun(); + await firstPost; + await page.waitForFunction(() => document.body.textContent.includes('Scanning...')); + await page.evaluate(() => window.discoveryOwnership.target('102')); + await page.getByText('Saved Home Assistant 102', { exact: true }).waitFor(); + const secondPost = new Promise((resolve) => arrivals.set('102', resolve)); + await pressRun(); + await secondPost; + await page.waitForFunction(() => document.body.textContent.includes('Scanning...')); + assert(releases.has('101')); + assert(releases.has('102')); + releases.get('101')(); + await page.waitForTimeout(150); + assert.equal(await page.getByText('Old guest result', { exact: true }).count(), 0); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled()); + await emit({ + resource_id: 'vm:fixture-node-agent:102', + status: 'completed', + percent_complete: 100, + }); + assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + releases.get('102')(); + await page.getByText('Current guest result', { exact: true }).waitFor(); + await page.getByText('Discovery complete!', { exact: true }).waitFor(); + group.checks.push( + 'Late old-target HTTP and scanner completion cannot replace the current guest or finish its active HTTP request', + ); + const posts = group.requests.filter((r) => r.method === 'POST'); + assert.equal(posts.length, 2); + assert.deepEqual( + posts.map((p) => JSON.parse(p.body)), + [ + { force: true, hostname: 'guest-101' }, + { force: true, hostname: 'guest-102' }, + ], + ); + await capture('current-guest-result'); + await emit({ resource_id: 'vm:fixture-node-agent:102', status: 'completed' }); + await page.waitForTimeout(650); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + assert(await page.getByText('Current guest result', { exact: true }).isVisible()); + assert.equal(group.requests.filter((r) => r.method === 'POST').length, 2); + group.checks.push( + 'Later completion cannot strand the old HTTP success banner or issue an automatic scan', + ); + fail = true; + await pressRun(); + await alert.waitFor(); + assert((await alert.innerText()).includes('Guest execution paused: backup lock observed')); + assert(await page.getByText('Current guest result', { exact: true }).isVisible()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + await page.waitForTimeout(650); + assert.equal(group.requests.filter((r) => r.method === 'POST').length, 3); + group.checks.push( + 'A failed explicit run retains prior saved evidence, visible failure and no automatic retry', + ); + assert.deepEqual(group.errors, []); + assert.deepEqual(group.offOrigin, []); + await browser.close(); + browser = null; + } + report.result = 'passed'; + report.verified_at = new Date().toISOString(); + } catch (error) { + report.result = 'failed'; + report.phase = phase; + report.error = String(error.stack || error); + throw error; + } finally { + if (browser) await browser.close(); + report.cleanup.browser_closed = true; + await server.close(); + report.cleanup.server_closed = true; + fs.writeFileSync(output + '/result.json', JSON.stringify(report, null, 2) + '\n'); + } +})().catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.html b/frontend-modern/browser-tests/discovery-outcome-ownership.html new file mode 100644 index 0000000000..7b3a737737 --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.html @@ -0,0 +1,12 @@ + + + + + + Discovery outcome ownership evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.tsx b/frontend-modern/browser-tests/discovery-outcome-ownership.tsx new file mode 100644 index 0000000000..c9fe99348c --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.tsx @@ -0,0 +1,54 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { DiscoveryTab } from '../src/components/Discovery/DiscoveryTab'; +import { eventBus } from '../src/stores/events'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { DiscoveryProgress } from '../src/types/discovery'; +import '../src/index.css'; + +declare global { + interface Window { + discoveryOwnership: { + target: (id: string) => void; + progress: (progress: DiscoveryProgress) => void; + enabled: (enabled: boolean) => void; + }; + } +} + +function Fixture() { + const [id, setId] = createSignal('101'); + const enabled = (discovery_enabled: boolean) => + syncAIRuntimeSettings({ discovery_enabled } as Parameters[0]); + enabled(true); + window.discoveryOwnership = { + target: setId, + progress: (progress) => eventBus.emit('ai_discovery_progress', progress), + enabled, + }; + return ( +
+

Discovery outcome ownership evidence

+

Synthetic guest responses. No guest command is sent.

+

Guest {id()}

+ +
+ ); +} + +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-tests/guest-discovery-completion.cjs b/frontend-modern/browser-tests/guest-discovery-completion.cjs new file mode 100644 index 0000000000..25bb445ee3 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-completion.cjs @@ -0,0 +1,19 @@ +const { spawnSync } = require('node:child_process'); +for (const script of ['guest-discovery-safety.cjs', 'discovery-outcome-ownership.cjs']) { + const result = spawnSync( + process.execPath, + ['/workspace/frontend-modern/browser-tests/' + script], + { stdio: 'inherit' }, + ); + if (result.status !== 0) { + console.error( + script, + 'did not complete successfully', + result.status, + result.signal, + result.error?.message, + ); + process.exit(result.status || 1); + } +} +console.log('Combined guest Discovery pause and outcome ownership browser proof passed.'); diff --git a/frontend-modern/browser-verification/c45de66a92dcfc91.json b/frontend-modern/browser-verification/c45de66a92dcfc91.json new file mode 100644 index 0000000000..88b556048b --- /dev/null +++ b/frontend-modern/browser-verification/c45de66a92dcfc91.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "base_sha": "bc3f4d81d14873caae6f769dfa670d16fa13d518", + "verified_at": "2026-10-04T20:32:45.762Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "2e93209a2b3830f77a0ad94d02bd59ea01509932f8d51e594d60f55d1f5895d0", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "e600bb993b80e85b6df48a9e070f1bc2db51d7bb8e5ffd6216e3861d610def14" + }, + "routes": [ + "/browser-tests/guest-discovery-safety.html: production Workloads row, GuestDrawer and DiscoveryTab with synthetic canonical snapshots", + "/browser-tests/discovery-outcome-ownership.html: reused production DiscoveryTab/state, synthetic per-guest APIs and production event-bus progress" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "states": [ + "Guest operation lock and all current guest-read deferrals with/without retained data; healthy explicit action", + "Original HTTP result retained after a same-target precaution without cancellation or another scan", + "Background completed-with-error retains an accessible failed outcome and saved evidence", + "Late old-target HTTP result cannot overwrite the current guest or stop its manual request", + "Scanner completion during HTTP does not claim saved success; later terminal event cannot strand earlier success", + "Current explicit failure remains visible without an automatic retry" + ], + "interactions": [ + "Keyboard and touch drawer expansion, Discovery tabs, explicit enabled runs and associated disabled reasons", + "No POST on blocked/unlock/tab/snapshot change and exactly one target/body POST per explicit healthy action", + "Keyboard-labelled error dismissal with saved content preserved", + "Change target during an in-flight request, deliver the old and current responses independently and inspect original ownership", + "Render desktop light/dark and WebKit 390px/320px, inspect wrapping and all 24 captures" + ], + "script": "frontend-modern/browser-tests/guest-discovery-completion.cjs", + "browsers": [ + { + "name": "desktop-light", + "browser": "141.0.7390.37", + "width": 1365, + "dark": false + }, + { + "name": "desktop-dark", + "browser": "141.0.7390.37", + "width": 1365, + "dark": true + }, + { + "name": "phone-dark", + "browser": "26.0", + "width": 390, + "dark": true + }, + { + "name": "narrow-light", + "browser": "26.0", + "width": 320, + "dark": false + } + ], + "limitations": [ + "Synthetic inputs and API responses, not native QGA, backup or automatic poller acceptance.", + "Vite-transformed production components. Exact production build is checked separately in the source-proof VM.", + "No original freeze cause, independent thaw, covered-filesystem writes, installed use, physical phone or containing release is established." + ] +} diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index 169fd12981..a95395eed0 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -474,7 +474,10 @@ export const DiscoveryTab: Component = (props) => { {/* Scan Error */} -
+