mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Integrate reviewed guest Discovery pause and outcome ownership
Preserve the exact seven candidate commits and their own-parent browser receipts. Retained source-gy5x8buj and the final browser matrices cover the existing drawer pause, visible failures and asynchronous target/run ownership; native backup safety remains separately owned. Change-source: pulse-maintainer
This commit is contained in:
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Discovery outcome ownership evidence</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./discovery-outcome-ownership.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<typeof syncAIRuntimeSettings>[0]);
|
||||
enabled(true);
|
||||
window.discoveryOwnership = {
|
||||
target: setId,
|
||||
progress: (progress) => eventBus.emit('ai_discovery_progress', progress),
|
||||
enabled,
|
||||
};
|
||||
return (
|
||||
<main class="mx-auto min-h-screen max-w-4xl space-y-3 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Discovery outcome ownership evidence</h1>
|
||||
<p class="text-xs text-muted">Synthetic guest responses. No guest command is sent.</p>
|
||||
<h2 class="text-sm font-medium">Guest {id()}</h2>
|
||||
<DiscoveryTab
|
||||
resourceType="vm"
|
||||
agentId="fixture-node-agent"
|
||||
resourceId={id()}
|
||||
canonicalResourceId={`fixture-pve1-${id()}`}
|
||||
hostname={`guest-${id()}`}
|
||||
showManualRunAction
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="/*" component={Fixture} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -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.');
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Guest Discovery safety verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./guest-discovery-safety.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<Resource[]>([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 (
|
||||
<main class="mx-auto min-h-screen max-w-5xl space-y-3 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Guest Discovery safety evidence</h1>
|
||||
<p class="text-xs text-muted">Synthetic observations. No guest check is sent.</p>
|
||||
<Show when={Boolean(guest())}>
|
||||
<table class="w-full table-fixed">
|
||||
<tbody>
|
||||
<GuestRow
|
||||
guest={guest()}
|
||||
visibleColumnIds={['name', 'disk']}
|
||||
isExpanded={expanded()}
|
||||
onClick={() => setExpanded(!expanded())}
|
||||
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={expanded()}>
|
||||
<section aria-label="Guest details">
|
||||
<GuestDrawer guest={guest()} onClose={() => setExpanded(false)} />
|
||||
</section>
|
||||
</Show>
|
||||
</Show>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
|
||||
<Router>
|
||||
<Route path="/docs/*docPath" component={Docs} />
|
||||
<Route path="/*" component={Fixture} />
|
||||
</Router>
|
||||
</DarkModeContext.Provider>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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<DiscoveryTabProps> = (props) => {
|
||||
@@ -75,6 +78,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
liveElapsedSeconds,
|
||||
notesText,
|
||||
mutateDiscovery,
|
||||
runBlockReason,
|
||||
saveError,
|
||||
scanError,
|
||||
scanProgress,
|
||||
@@ -90,6 +94,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (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<DiscoveryTabProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={runBlockReason()}>
|
||||
{(reason) => (
|
||||
<InlineNotice
|
||||
id={runBlockReasonId}
|
||||
tone="warning"
|
||||
role="status"
|
||||
data-testid="discovery-run-block"
|
||||
>
|
||||
{reason()}
|
||||
</InlineNotice>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={showManualRunAction()}>
|
||||
<InfoCardFrame>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -236,6 +254,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (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"
|
||||
>
|
||||
<Show
|
||||
@@ -455,7 +474,10 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
|
||||
{/* Scan Error */}
|
||||
<Show when={scanError()}>
|
||||
<div class="mb-4 rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/25">
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-4 rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/25"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-red-500 dark:text-red-400 shrink-0 mt-0.5"
|
||||
@@ -471,10 +493,14 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-red-800 dark:text-red-200">Discovery Failed</p>
|
||||
<p class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Discovery did not complete
|
||||
</p>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">{scanError()}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss Discovery error"
|
||||
onClick={() => setScanError(null)}
|
||||
class="ml-auto text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
@@ -633,6 +659,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
<button
|
||||
onClick={() => handleTriggerDiscovery(true)}
|
||||
disabled={isScanning() || !canTriggerDiscovery()}
|
||||
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isScanning() ? (
|
||||
@@ -708,6 +735,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
<button
|
||||
onClick={() => handleTriggerDiscovery(true)}
|
||||
disabled={isScanning() || !canTriggerDiscovery()}
|
||||
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isScanning() ? (
|
||||
@@ -1192,6 +1220,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
<button
|
||||
onClick={() => handleTriggerDiscovery(true)}
|
||||
disabled={isScanning() || !canTriggerDiscovery()}
|
||||
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
|
||||
class="px-3 py-1.5 bg-surface-hover text-base-content text-xs rounded-sm hover:bg-surface-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Show
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { cleanup, render, waitFor } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ResourceDiscovery } from '@/types/discovery';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState';
|
||||
import * as api from '@/api/discovery';
|
||||
import { useDiscoveryTabState } from '../useDiscoveryTabState';
|
||||
|
||||
vi.mock('@/api/discovery', () => ({
|
||||
getDiscovery: vi.fn(async () => null),
|
||||
getDiscoveryInfo: vi.fn(async () => ({
|
||||
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
|
||||
})),
|
||||
getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })),
|
||||
triggerDiscovery: vi.fn(),
|
||||
updateDiscoveryNotes: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const saved = (id = '100'): ResourceDiscovery => ({
|
||||
id: `vm:node-agent:${id}`,
|
||||
resource_type: 'vm',
|
||||
resource_id: id,
|
||||
target_id: 'node-agent',
|
||||
hostname: `guest-${id}`,
|
||||
service_type: 'home-assistant',
|
||||
service_name: `Saved service ${id}`,
|
||||
service_version: 'fixture',
|
||||
category: 'home_automation',
|
||||
cli_access: '',
|
||||
user_secrets: {},
|
||||
ai_reasoning: 'Synthetic saved evidence',
|
||||
scan_duration: 0,
|
||||
facts: [],
|
||||
config_paths: [],
|
||||
data_paths: [],
|
||||
log_paths: [],
|
||||
ports: [],
|
||||
user_notes: '',
|
||||
discovered_at: '2026-10-03T12:00:00Z',
|
||||
updated_at: '2026-10-03T12:00:00Z',
|
||||
confidence: 0.9,
|
||||
});
|
||||
|
||||
async function mount(initialAgent = 'node-agent') {
|
||||
const [id, setId] = createSignal('100');
|
||||
const [agent, setAgent] = createSignal(initialAgent);
|
||||
let state!: ReturnType<typeof useDiscoveryTabState>;
|
||||
const view = render(() => {
|
||||
state = useDiscoveryTabState({
|
||||
resourceType: 'vm',
|
||||
get agentId() {
|
||||
return agent();
|
||||
},
|
||||
get resourceId() {
|
||||
return id();
|
||||
},
|
||||
get hostname() {
|
||||
return `guest-${id()}`;
|
||||
},
|
||||
});
|
||||
return <span>Discovery lifecycle</span>;
|
||||
});
|
||||
await waitFor(() => expect(state.discoveryInfo.loading).toBe(false));
|
||||
await waitFor(() => expect(state.discovery.loading).toBe(false));
|
||||
return { state, setId, setAgent, dispose: view.unmount };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetAIRuntimeState();
|
||||
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
|
||||
vi.mocked(api.getDiscovery).mockImplementation(async (_type, _agent, id) => saved(id));
|
||||
vi.mocked(api.getDiscoveryInfo).mockResolvedValue({
|
||||
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
|
||||
});
|
||||
vi.mocked(api.getConnectedAgents).mockResolvedValue({ count: 0, agents: [] });
|
||||
vi.mocked(api.triggerDiscovery).mockResolvedValue(saved());
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetAIRuntimeState();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Discovery outcome ownership', () => {
|
||||
it.each(['success', 'failure'] as const)(
|
||||
'does not apply an old target HTTP %s to the new guest or its in-flight run',
|
||||
async (outcome) => {
|
||||
const old = deferred<ResourceDiscovery>();
|
||||
const current = deferred<ResourceDiscovery>();
|
||||
vi.mocked(api.triggerDiscovery)
|
||||
.mockReturnValueOnce(old.promise)
|
||||
.mockReturnValueOnce(current.promise);
|
||||
const { state, setId } = await mount();
|
||||
const first = state.handleTriggerDiscovery(true);
|
||||
setId('101');
|
||||
await waitFor(() => expect(state.discovery()?.resource_id).toBe('101'));
|
||||
const second = state.handleTriggerDiscovery(true);
|
||||
if (outcome === 'success') old.resolve(saved());
|
||||
else old.reject(new Error('Old guest execution paused'));
|
||||
await first;
|
||||
expect(state.discovery()?.resource_id).toBe('101');
|
||||
expect(state.isScanning()).toBe(true);
|
||||
expect(state.scanError()).toBeNull();
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
current.resolve(saved('101'));
|
||||
await second;
|
||||
expect(state.scanSuccess()).toBe(true);
|
||||
expect(api.triggerDiscovery).toHaveBeenNthCalledWith(2, 'vm', 'node-agent', '101', {
|
||||
force: true,
|
||||
hostname: 'guest-101',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('invalidates an old run even when the operator leaves and returns to the same target', async () => {
|
||||
const old = deferred<ResourceDiscovery>();
|
||||
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
|
||||
const { state, setId } = await mount();
|
||||
const first = state.handleTriggerDiscovery(true);
|
||||
setId('101');
|
||||
await waitFor(() => expect(state.discovery()?.resource_id).toBe('101'));
|
||||
setId('100');
|
||||
await waitFor(() => expect(state.discovery()?.resource_id).toBe('100'));
|
||||
old.resolve({ ...saved(), service_name: 'Old run result' });
|
||||
await first;
|
||||
expect(state.discovery()?.service_name).toBe('Saved service 100');
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not resurrect a run after Discovery was disabled and re-enabled', async () => {
|
||||
const old = deferred<ResourceDiscovery>();
|
||||
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
|
||||
const { state } = await mount();
|
||||
const first = state.handleTriggerDiscovery(true);
|
||||
syncAIRuntimeSettings({ discovery_enabled: false } as Parameters<
|
||||
typeof syncAIRuntimeSettings
|
||||
>[0]);
|
||||
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(false));
|
||||
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<
|
||||
typeof syncAIRuntimeSettings
|
||||
>[0]);
|
||||
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true));
|
||||
old.resolve({ ...saved(), service_name: 'Disabled run result' });
|
||||
await first;
|
||||
expect(state.discovery()?.service_name).toBe('Saved service 100');
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores HTTP completion after disposal without scheduling another success timer', async () => {
|
||||
const old = deferred<ResourceDiscovery>();
|
||||
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise);
|
||||
const { state, dispose } = await mount();
|
||||
vi.useFakeTimers();
|
||||
const first = state.handleTriggerDiscovery(true);
|
||||
dispose();
|
||||
old.resolve(saved());
|
||||
await first;
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not leave a spinner running when a retained handler has no target agent', async () => {
|
||||
const { state } = await mount('');
|
||||
await state.handleTriggerDiscovery(true);
|
||||
expect(api.triggerDiscovery).not.toHaveBeenCalled();
|
||||
expect(state.scanError()).toBe('Agent identifier unavailable for discovery');
|
||||
expect(state.isScanning()).toBe(false);
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let an earlier success timer clear the success of a later explicit run', async () => {
|
||||
const { state } = await mount();
|
||||
vi.useFakeTimers();
|
||||
await state.handleTriggerDiscovery(true);
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
await state.handleTriggerDiscovery(true);
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(state.scanSuccess()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(1400);
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not leave the previous HTTP success visible forever after a later completion event', async () => {
|
||||
const { state } = await mount();
|
||||
vi.useFakeTimers();
|
||||
await state.handleTriggerDiscovery(true);
|
||||
eventBus.emit('ai_discovery_progress', {
|
||||
resource_id: 'vm:node-agent:100',
|
||||
status: 'completed',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
expect(state.discovery()?.resource_id).toBe('100');
|
||||
});
|
||||
|
||||
it.each(['failed', 'completed'] as const)(
|
||||
'retains a background %s-with-error outcome and the saved evidence, not silent success',
|
||||
async (status) => {
|
||||
const { state } = await mount();
|
||||
const before = vi.mocked(api.getDiscovery).mock.calls.length;
|
||||
vi.useFakeTimers();
|
||||
eventBus.emit('ai_discovery_progress', {
|
||||
resource_id: 'vm:node-agent:100',
|
||||
status,
|
||||
error: 'Guest execution paused: backup lock observed',
|
||||
current_step: 'Guest execution paused',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(state.scanError()).toBe('Guest execution paused: backup lock observed');
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
expect(state.discovery()?.service_name).toBe('Saved service 100');
|
||||
expect(api.getDiscovery).toHaveBeenCalledTimes(before);
|
||||
expect(api.triggerDiscovery).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('shows background progress without claiming completion of a manual HTTP request', async () => {
|
||||
const http = deferred<ResourceDiscovery>();
|
||||
vi.mocked(api.triggerDiscovery).mockReturnValueOnce(http.promise);
|
||||
const { state } = await mount();
|
||||
eventBus.emit('ai_discovery_progress', {
|
||||
resource_id: 'vm:node-agent:100',
|
||||
status: 'running',
|
||||
current_step: 'Collecting guest evidence',
|
||||
});
|
||||
expect(state.isScanning()).toBe(true);
|
||||
eventBus.emit('ai_discovery_progress', {
|
||||
resource_id: 'vm:node-agent:100',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(state.isScanning()).toBe(false);
|
||||
const first = state.handleTriggerDiscovery(true);
|
||||
eventBus.emit('ai_discovery_progress', {
|
||||
resource_id: 'vm:node-agent:100',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(state.isScanning()).toBe(true);
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
http.resolve(saved());
|
||||
await first;
|
||||
expect(state.scanSuccess()).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a delayed background refresh when its target changed before dispatch', async () => {
|
||||
const { state, setId } = await mount();
|
||||
vi.useFakeTimers();
|
||||
eventBus.emit('ai_discovery_progress', {
|
||||
resource_id: 'vm:node-agent:100',
|
||||
status: 'completed',
|
||||
});
|
||||
setId('101');
|
||||
await Promise.resolve();
|
||||
const before = vi.mocked(api.getDiscovery).mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(api.getDiscovery).toHaveBeenCalledTimes(before);
|
||||
expect(state.discovery()?.resource_id).toBe('101');
|
||||
expect(api.triggerDiscovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not apply a delayed background read to a newer manual result on the same guest', async () => {
|
||||
const refresh = deferred<ResourceDiscovery | null>();
|
||||
const { state } = await mount();
|
||||
vi.mocked(api.getDiscovery).mockReturnValueOnce(refresh.promise);
|
||||
vi.useFakeTimers();
|
||||
eventBus.emit('ai_discovery_progress', {
|
||||
resource_id: 'vm:node-agent:100',
|
||||
status: 'completed',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
vi.mocked(api.triggerDiscovery).mockResolvedValueOnce({
|
||||
...saved(),
|
||||
service_name: 'New manual result',
|
||||
});
|
||||
await state.handleTriggerDiscovery(true);
|
||||
refresh.resolve({ ...saved(), service_name: 'Old background result' });
|
||||
await Promise.resolve();
|
||||
expect(state.discovery()?.service_name).toBe('New manual result');
|
||||
expect(state.scanSuccess()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
'withdraws saved evidence when a completion refresh is denied with %s',
|
||||
async (status) => {
|
||||
const { state } = await mount();
|
||||
vi.mocked(api.getDiscovery).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Discovery details unavailable.'), { status }),
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
eventBus.emit('ai_discovery_progress', {
|
||||
resource_id: 'vm:node-agent:100',
|
||||
status: 'completed',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(state.discovery()).toBeNull();
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
expect(api.triggerDiscovery).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps saved evidence without success when HTTP supplies no result', async () => {
|
||||
vi.mocked(api.triggerDiscovery).mockResolvedValueOnce(null as unknown as ResourceDiscovery);
|
||||
const { state } = await mount();
|
||||
await state.handleTriggerDiscovery(true);
|
||||
expect(state.scanError()).toBe('Discovery returned no saved result.');
|
||||
expect(state.scanSuccess()).toBe(false);
|
||||
expect(state.discovery()?.service_name).toBe('Saved service 100');
|
||||
expect(state.isScanning()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not close another guest’s notes editor when an old save finishes', async () => {
|
||||
const save = deferred<ResourceDiscovery>();
|
||||
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(saved());
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
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<typeof import('@/api/discovery')>()),
|
||||
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: ResourceDiscovery = {
|
||||
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: 'home_automation',
|
||||
service_version: 'fixture',
|
||||
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,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAIRuntimeState();
|
||||
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
|
||||
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
|
||||
vi.mocked(discoveryApi.triggerDiscovery).mockResolvedValue(saved);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetAIRuntimeState();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('manual discovery safety', () => {
|
||||
it.each([
|
||||
['loading', (): Promise<ResourceDiscovery | null> => new Promise(() => undefined)],
|
||||
['absent', async (): Promise<ResourceDiscovery | null> => null],
|
||||
[
|
||||
'empty',
|
||||
async (): Promise<ResourceDiscovery | null> => ({
|
||||
...saved,
|
||||
service_type: '',
|
||||
service_name: '',
|
||||
confidence: 0,
|
||||
}),
|
||||
],
|
||||
['saved', async (): Promise<ResourceDiscovery | null> => 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(() => <DiscoveryTab {...props} />);
|
||||
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<string | null>(reason);
|
||||
const props = {
|
||||
...target,
|
||||
get runBlockReason() {
|
||||
return block();
|
||||
},
|
||||
showManualRunAction: true,
|
||||
};
|
||||
render(() => (
|
||||
<Suspense>
|
||||
<DiscoveryTab {...props} />
|
||||
</Suspense>
|
||||
));
|
||||
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<string | null>(null);
|
||||
const props = {
|
||||
...target,
|
||||
get runBlockReason() {
|
||||
return block();
|
||||
},
|
||||
};
|
||||
let state!: ReturnType<typeof useDiscoveryTabState>;
|
||||
render(() => {
|
||||
state = useDiscoveryTabState(props);
|
||||
return <span>state</span>;
|
||||
});
|
||||
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 () => {
|
||||
const finishes: Array<(value: ResourceDiscovery) => void> = [];
|
||||
vi.mocked(discoveryApi.triggerDiscovery).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishes.push(resolve);
|
||||
}),
|
||||
);
|
||||
const [block, setBlock] = createSignal<string | null>(null);
|
||||
const props = {
|
||||
...target,
|
||||
get runBlockReason() {
|
||||
return block();
|
||||
},
|
||||
};
|
||||
let state!: ReturnType<typeof useDiscoveryTabState>;
|
||||
render(() => {
|
||||
state = useDiscoveryTabState(props);
|
||||
return <span>state</span>;
|
||||
});
|
||||
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true));
|
||||
const original = state.handleTriggerDiscovery(true);
|
||||
expect(state.isScanning()).toBe(true);
|
||||
setBlock(reason);
|
||||
const second = state.handleTriggerDiscovery(true);
|
||||
try {
|
||||
expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1);
|
||||
expect(state.isScanning()).toBe(true);
|
||||
} finally {
|
||||
for (const finish of finishes) finish(saved);
|
||||
await Promise.all([original, second]);
|
||||
}
|
||||
expect(state.isScanning()).toBe(false);
|
||||
expect(state.discovery()).toBe(saved);
|
||||
expect(state.scanSuccess()).toBe(true);
|
||||
setBlock(null);
|
||||
expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ export interface DiscoveryTabStateProps {
|
||||
resourceId: string;
|
||||
hostname: string;
|
||||
commandsEnabled?: boolean;
|
||||
/** Snapshot-based safety pause for manual runs; saved reads remain available. */
|
||||
runBlockReason?: string | null;
|
||||
}
|
||||
|
||||
const makeResourceId = (type: ResourceType, agentId: string, resourceId: string) =>
|
||||
@@ -45,6 +47,18 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
const [httpScanInProgress, setHttpScanInProgress] = createSignal(false);
|
||||
const [copiedDiscoveryValue, setCopiedDiscoveryValue] = createSignal('');
|
||||
let copyFeedbackTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let successTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let completionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let contextVersion = 0;
|
||||
let runVersion = 0;
|
||||
let disposed = false;
|
||||
|
||||
const clearScanTimers = () => {
|
||||
clearTimeout(successTimer);
|
||||
clearTimeout(completionTimer);
|
||||
successTimer = undefined;
|
||||
completionTimer = undefined;
|
||||
};
|
||||
|
||||
const targetAgentId = createMemo(() => props.agentId || '');
|
||||
const discoverySourceKey = createMemo(
|
||||
@@ -57,6 +71,23 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
const { discoveryFeatureEnabled, discoveryFeatureKnownDisabled } =
|
||||
useDiscoveryFeatureAvailability();
|
||||
|
||||
// A target key alone is insufficient: leaving and returning to the same
|
||||
// guest must not admit a response from the previous visit. A precaution on
|
||||
// the same target does not change this ownership or cancel dispatched work.
|
||||
const captureContext = () => ({
|
||||
version: contextVersion,
|
||||
key: discoverySourceKey(),
|
||||
type: props.resourceType,
|
||||
agentId: targetAgentId(),
|
||||
resourceId: props.resourceId,
|
||||
hostname: props.hostname,
|
||||
});
|
||||
const isCurrentContext = (context: ReturnType<typeof captureContext>) =>
|
||||
!disposed &&
|
||||
context.version === contextVersion &&
|
||||
context.key === discoverySourceKey() &&
|
||||
discoveryFeatureEnabled();
|
||||
|
||||
const [discoveryInfo] = createResource(
|
||||
() => (discoveryFeatureEnabled() ? props.resourceType : null),
|
||||
async (type) => {
|
||||
@@ -120,8 +151,13 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
// the tab-wide banner already tells the user to configure one before
|
||||
// scanning. (Command/connectivity gaps are surfaced separately, not blocked
|
||||
// here, since their backend semantics are murkier.)
|
||||
const runBlockReason = createMemo(() => props.runBlockReason?.trim() || null);
|
||||
const canTriggerDiscovery = createMemo(
|
||||
() => discoveryFeatureEnabled() && aiProviderConfigured() && Boolean(targetAgentId()),
|
||||
() =>
|
||||
discoveryFeatureEnabled() &&
|
||||
aiProviderConfigured() &&
|
||||
Boolean(targetAgentId()) &&
|
||||
!runBlockReason(),
|
||||
);
|
||||
|
||||
const [discovery, { refetch, mutate }] = createResource(
|
||||
@@ -143,6 +179,9 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
createEffect(() => {
|
||||
void discoveryFeatureEnabled();
|
||||
void discoverySourceKey();
|
||||
contextVersion++;
|
||||
runVersion++;
|
||||
clearScanTimers();
|
||||
setIsScanning(false);
|
||||
setHttpScanInProgress(false);
|
||||
setScanProgress(null);
|
||||
@@ -185,11 +224,23 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
});
|
||||
|
||||
const handleTriggerDiscovery = async (force = false) => {
|
||||
// Check the current snapshot at the dispatch boundary as well as disabling
|
||||
// buttons. Do not cancel a dispatched scan when a precaution appears.
|
||||
if (disposed || runBlockReason() || isScanning()) return;
|
||||
if (!discoveryFeatureEnabled()) {
|
||||
setScanError('Service context is disabled in Settings -> Pulse Intelligence -> Assistant.');
|
||||
return;
|
||||
}
|
||||
const context = captureContext();
|
||||
if (!context.agentId) {
|
||||
setScanError('Agent identifier unavailable for discovery');
|
||||
return;
|
||||
}
|
||||
if (!aiProviderConfigured()) return;
|
||||
|
||||
clearScanTimers();
|
||||
const version = ++runVersion;
|
||||
const ownsRun = () => isCurrentContext(context) && version === runVersion;
|
||||
setIsScanning(true);
|
||||
setHttpScanInProgress(true);
|
||||
setScanProgress(null);
|
||||
@@ -199,59 +250,58 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
setLiveElapsedSeconds(0);
|
||||
|
||||
try {
|
||||
const agentId = targetAgentId();
|
||||
if (!agentId) {
|
||||
setScanError('Agent identifier unavailable for discovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await triggerDiscovery(props.resourceType, agentId, props.resourceId, {
|
||||
const result = await triggerDiscovery(context.type, context.agentId, context.resourceId, {
|
||||
force,
|
||||
hostname: props.hostname,
|
||||
hostname: context.hostname,
|
||||
});
|
||||
if (result) {
|
||||
mutate(result);
|
||||
}
|
||||
|
||||
setHttpScanInProgress(false);
|
||||
setIsScanning(false);
|
||||
setScanProgress(null);
|
||||
setScanStartTime(null);
|
||||
if (!ownsRun()) return;
|
||||
if (!result) throw new Error('Discovery returned no saved result.');
|
||||
mutate(result);
|
||||
setScanError(null);
|
||||
setScanSuccess(true);
|
||||
setTimeout(() => setScanSuccess(false), 2000);
|
||||
successTimer = setTimeout(() => {
|
||||
successTimer = undefined;
|
||||
if (ownsRun()) setScanSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
if (!ownsRun()) return;
|
||||
console.error('Discovery failed:', err);
|
||||
const message = err instanceof Error ? err.message : 'Discovery scan failed';
|
||||
|
||||
if (message.includes('no connected agent')) {
|
||||
setScanError(getDiscoveryNoConnectedAgentMessage(props.commandsEnabled));
|
||||
} else {
|
||||
setScanError(message);
|
||||
setScanError(
|
||||
message.includes('no connected agent')
|
||||
? getDiscoveryNoConnectedAgentMessage(props.commandsEnabled)
|
||||
: message,
|
||||
);
|
||||
} finally {
|
||||
if (ownsRun()) {
|
||||
setHttpScanInProgress(false);
|
||||
setIsScanning(false);
|
||||
setScanProgress(null);
|
||||
setScanStartTime(null);
|
||||
}
|
||||
|
||||
setHttpScanInProgress(false);
|
||||
setIsScanning(false);
|
||||
setScanProgress(null);
|
||||
setScanStartTime(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNotes = async () => {
|
||||
if (disposed || !discoveryFeatureEnabled()) return;
|
||||
setSaveError(null);
|
||||
const agentId = targetAgentId();
|
||||
if (!agentId) {
|
||||
const context = captureContext();
|
||||
if (!context.agentId) {
|
||||
setSaveError('Agent identifier unavailable for discovery');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateDiscoveryNotes(props.resourceType, agentId, props.resourceId, {
|
||||
await updateDiscoveryNotes(context.type, context.agentId, context.resourceId, {
|
||||
user_notes: notesText(),
|
||||
});
|
||||
if (!isCurrentContext(context)) return;
|
||||
setEditingNotes(false);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save notes');
|
||||
if (isCurrentContext(context)) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save notes');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -267,14 +317,19 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true;
|
||||
contextVersion++;
|
||||
runVersion++;
|
||||
clearScanTimers();
|
||||
clearCopyFeedbackTimer();
|
||||
});
|
||||
|
||||
const handleCopyDiscoveryValue = async (value?: string | null) => {
|
||||
const text = (value || '').trim();
|
||||
if (!text) return;
|
||||
const context = captureContext();
|
||||
const copied = await copyToClipboard(text);
|
||||
if (!copied) return;
|
||||
if (!copied || !isCurrentContext(context)) return;
|
||||
|
||||
clearCopyFeedbackTimer();
|
||||
setCopiedDiscoveryValue(text);
|
||||
@@ -291,28 +346,56 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
if (!progress || progress.resource_id !== resourceId()) return;
|
||||
|
||||
setScanProgress(progress);
|
||||
|
||||
if (
|
||||
(progress.status === 'completed' || progress.status === 'failed') &&
|
||||
!httpScanInProgress()
|
||||
) {
|
||||
setIsScanning(false);
|
||||
setTimeout(async () => {
|
||||
const agentId = targetAgentId();
|
||||
if (!agentId) return;
|
||||
|
||||
try {
|
||||
const result = await getDiscovery(props.resourceType, agentId, props.resourceId);
|
||||
if (result) {
|
||||
mutate(result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch discovery after completion:', err);
|
||||
}
|
||||
|
||||
setScanProgress(null);
|
||||
}, 500);
|
||||
if (progress.status === 'running' || progress.status === 'pending') {
|
||||
if (!httpScanInProgress() && !isScanning()) {
|
||||
clearScanTimers();
|
||||
runVersion++;
|
||||
setScanError(null);
|
||||
setScanSuccess(false);
|
||||
const startedAt = Date.parse(progress.started_at || '');
|
||||
setScanStartTime(Number.isFinite(startedAt) ? startedAt : Date.now());
|
||||
}
|
||||
setIsScanning(true);
|
||||
return;
|
||||
}
|
||||
if (progress.status !== 'completed' && progress.status !== 'failed') return;
|
||||
|
||||
// The scanner can emit completed-with-error when QGA is paused or no
|
||||
// command evidence was collected. It is not successful saved Discovery.
|
||||
// Keep that reason visible even when this tab did not initiate the scan.
|
||||
const error =
|
||||
progress.error?.trim() || (progress.status === 'failed' ? 'Discovery scan failed.' : null);
|
||||
if (error) {
|
||||
setScanError(error);
|
||||
setScanSuccess(false);
|
||||
}
|
||||
// Scanner completion can precede analysis/persistence. A manual request
|
||||
// owns its final result until HTTP settles, not an intermediate event.
|
||||
if (httpScanInProgress()) return;
|
||||
|
||||
clearScanTimers();
|
||||
const version = ++runVersion;
|
||||
setScanSuccess(false);
|
||||
setIsScanning(false);
|
||||
setScanStartTime(null);
|
||||
setScanProgress(null);
|
||||
if (error) return;
|
||||
|
||||
const context = captureContext();
|
||||
const ownsRefresh = () => isCurrentContext(context) && version === runVersion;
|
||||
completionTimer = setTimeout(async () => {
|
||||
completionTimer = undefined;
|
||||
if (!context.agentId || !ownsRefresh()) return;
|
||||
try {
|
||||
const result = await getDiscovery(context.type, context.agentId, context.resourceId);
|
||||
if (ownsRefresh()) mutate(result);
|
||||
} catch (err) {
|
||||
if (!ownsRefresh()) return;
|
||||
const status = (err as { status?: number } | null)?.status;
|
||||
if (status === 401 || status === 403) mutate(null);
|
||||
console.error('Failed to fetch discovery after completion:', err);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -347,6 +430,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
notesText,
|
||||
mutateDiscovery: mutate,
|
||||
refetchDiscovery: refetch,
|
||||
runBlockReason,
|
||||
saveError,
|
||||
scanError,
|
||||
scanProgress,
|
||||
|
||||
@@ -198,6 +198,11 @@ const GuestDrawerContent: Component<GuestDrawerProps> = (props) => {
|
||||
hostname={props.guest.name}
|
||||
canonicalResourceId={props.guest.id}
|
||||
showManualRunAction
|
||||
runBlockReason={
|
||||
guestReadPrecaution()
|
||||
? 'Discovery is paused during a backup or guest-read deferral. Saved results remain available. Clearing this pause does not prove thaw.'
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
+161
@@ -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<typeof import('@/api/discovery')>()),
|
||||
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<typeof import('@/api/charts')>()),
|
||||
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> = {}): 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<typeof syncAIRuntimeSettings>[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(() => (
|
||||
<Suspense>
|
||||
<GuestDrawer guest={guest({ diskStatusReason: reason })} onClose={vi.fn()} />
|
||||
</Suspense>
|
||||
));
|
||||
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(() => (
|
||||
<Suspense>
|
||||
<GuestDrawer guest={guest(patch)} onClose={vi.fn()} />
|
||||
</Suspense>
|
||||
));
|
||||
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(() => (
|
||||
<Suspense>
|
||||
<GuestDrawer guest={value()} onClose={vi.fn()} />
|
||||
</Suspense>
|
||||
));
|
||||
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(() => (
|
||||
<Suspense>
|
||||
<GuestDrawer guest={guest(patch)} onClose={vi.fn()} />
|
||||
</Suspense>
|
||||
));
|
||||
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));
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user