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-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/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 6de5668272..a95395eed0 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) => {
{/* Scan Error */}
-
+
-
Discovery Failed
+
+ Discovery did not complete
+
{scanError()}
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..f8c98610a1
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx
@@ -0,0 +1,161 @@
+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));
+ },
+ );
+});