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 */} -
+