Discovery outcome ownership evidence
+Synthetic guest responses. No guest command is sent.
+diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 270d15e838..8578f16dd9 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -101,48 +101,114 @@ All endpoints require admin authentication. ### Testing and adding a connection (API) -The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client, -prepare the private Pulse header file as described in the -[API authentication guide](API.md). The Pulse API token authenticates to -Pulse; the separate TrueNAS API key belongs in the connection's JSON file. -Do not put either credential in shell arguments, URLs or a public thread. +Prefer **Settings → Infrastructure → Platform connections** for setup. These +API examples are for an authorised administrator using curl 7.76 or later, +not a way to diagnose stale polling. Testing makes a live system-information +request to TrueNAS; saving creates a connection and starts ordinary polling. +Do not use either during a backup, freeze/thaw or an unresponsive-host incident. -Create or protect the connection file before opening it in a trusted editor: +First prepare a private Pulse header file using the +[API token file procedure](API.md#api-token-recommended), with the admin access +needed for connection management. The Pulse token authenticates to Pulse; +the separate TrueNAS API key belongs in the connection's JSON file. Enter +credentials only in your local editor, never in arguments, URLs or a thread. + +On a trusted machine, as the account running curl, prepare the connection +file below. Stop if either file-preparation step fails. This preserves an +existing file, restricts the directory and refuses symlinked credential paths +or a non-regular connection file before opening the editor: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/truenas-connection.json" -chmod 600 "$HOME/.config/pulse/truenas-connection.json" -vi "$HOME/.config/pulse/truenas-connection.json" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + connection_file="$config_dir/truenas-connection.json" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then + printf 'Connection file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$connection_file" + chmod 600 "$connection_file" + vi "$connection_file" +) ``` -In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL), -`username` (the key owner) and `apiKey`. Keep both credential files private; -do not paste their contents into a terminal command or upload them. +In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL), +`username` (the key owner) and `apiKey`. Keep both credential files private and +outside repositories and diagnostics. Do not upload them. -Test the connection before saving it: +The following examples run **on the Pulse host**, using its direct loopback +address. They ignore curl configuration, do not follow redirects or retry, +and retain each response in a new private local file. Responses and errors can +contain identifying details or echoed credentials; review them locally, not in +a public terminal recording or thread. Keep `--disable` first so local curl +configuration cannot enable tracing or change the request. + +Test once without saving: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections/test +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections/test) + printf 'HTTP %s\n' "$status" + [ "$status" = 200 ] +) ``` -After checking the test response, save the connection with: +Require exit 0, HTTP **200** and `success: true` in that private test response. +A redirect, HTTP error or `000` is not a successful test. Test success is not +inventory, metric or guest-thaw acceptance. + +Only after reviewing the test response, create the connection once: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections) + printf 'HTTP %s\n' "$status" + [ "$status" = 201 ] +) ``` -These Pulse URLs are loopback-only examples. For a remote Pulse server, use -its trusted HTTPS origin without disabling certificate verification. +A successful save returns HTTP **201** and the new connection's `id`, not +proof of a complete poll. If saving times out or loses its response, it may +already have created the connection. Check **Platform connections** or +`/api/truenas/connections` in your existing signed-in Pulse browser before +trying again; do not blindly repeat POST and create duplicates. Continue with +the [ordinary polling checks](#stale-truenas-data) for collection acceptance. + +For remote Pulse access, replace the loopback origin in the relevant command +with your trusted **HTTPS** origin and change `--proto '=http'` to +`--proto '=https'`. Keep certificate verification enabled. For a private CA, +add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from +your administrator, not from the failing connection. Do not add `--insecure`, +`--location`, verbose/trace flags or automatic retries. Share only the relevant +HTTP status and a manually redacted error, never the full response or headers. ## Troubleshooting diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index f51a3f5a9f..d441026bb5 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -15,6 +15,35 @@ ## Purpose +### Retained guest observations are not alert evidence + +Guest memory marked `last-known`, `unavailable` or an unknown observation state +cannot start, advance, refresh or resolve a memory incident. A VM filesystem +read with a nonempty `diskStatusReason`, including retained `prev-*` values, +or unavailable aggregate usage is likewise not current disk evidence. It cannot +evaluate either aggregate or per-filesystem thresholds, replace an aggregate +incident with filesystem incidents, or clear filesystem alerts as if a failed +or expired inventory were authoritative removal. Metric filters do not match +these unavailable memory/disk values, including a fabricated healthy zero. + +Existing incidents retain their last trusted value, occurrence, acknowledgement, +observation time and lifecycle/delivery evidence until a current observation +resumes evaluation. Successful current inventory still resolves removed +filesystems. Explicit disablement, suppression and stopped-guest behavior remain +effective independently of telemetry, including per-filesystem disable overrides +on retained identities. Legacy unannotated memory and independent +current PVE/Pulse-agent memory/disks keep their existing policy, even while QGA +is deferred; current CPU and I/O continue to evaluate. Producers own freshness; +no new age threshold, QGA request, payload, alert family or UI surface is added. + +`guest_observation_test.go` pins typed/pointer VM/LXC observation admission, +delayed breaches, canonical incident/acknowledgement/ledger continuity, filters, +fresh recovery and removal, independent metrics and deliberate disablement. +`TestGuestBackupDeferralAlertObservationLifecycle` connects actual HTTP client, +poll builder, retained snapshot, unified view, History and the production alert +call. These are synthetic source proofs, not native backup/thaw, containing +release, installed delivery or external recipient acceptance. + ### Pulse system alerts retain their service scope in the existing UI The `pulse-system-` identity prefix or explicit `systemAlert: true` metadata diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 49baf2e21a..ef1326e310 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -249,6 +249,24 @@ Fresh linked Pulse-agent disk evidence with no reason remains unmarked, and non-VM rows do not inherit QGA guidance. The cue adds no action or tab stop; touch on it continues through the existing row disclosure to the full reason. +The opened guest Overview applies the same admission to every filesystem, not +only the aggregate row or its status sentence. A valid `prev-` value is labelled +**Last known** beside its mount identity and used/capacity bytes; retained values +do not receive current utilization bars or threshold colours. Other VM read +reasons and explicit disk telemetry unavailability show **Usage unavailable**, +retaining identity and known capacity without promoting old used bytes. Missing +or invalid retained usage is unavailable, not an empty healthy filesystem. The +read explanation precedes the filesystem breakdown and remains visible before +any sample. Clearing a lock alone cannot clear a still-supplied reason. A fresh +same-guest read restores the normal accessible utilization bar; independent LXC +and linked-agent readings with no VM reason retain their existing semantics. + +Verification: `GuestDrawerOverview.filesystem-provenance.test.tsx`, the existing +filesystem regressions and builder controls in `DiskList.test.tsx`, plus +`browser-tests/guest-filesystem-provenance.cjs` using the full production guest +drawer on desktop/light/dark and touch WebKit. These are synthetic presentation +and input proofs, not native backup/thaw, covered-filesystem writes or resumption. + Verification: mounted `GuestRow.test.tsx` and `MetricMiniSparkline.test.tsx` regressions and `browser-tests/guest-disk-provenance.cjs` exercise retained and pre-sample states, display/lens changes, current-value semantics and same-row @@ -8308,3 +8326,34 @@ chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS, 403/503 controls and pending recovery. Unit controls separately cover current request ownership and disposal. No mutation, route, navigation entry, queue policy, permission or recipient-delivery contract is added by these states. + +### Drawer History does not interpolate across missing observations + +The shared `GuestDrawerHistory` renderer connects a series only across +consecutive stored times in that configured panel. If another series has a +stored observation at an intervening time and this series does not, its line +stops. Each isolated observation, including a measured zero, remains visible +as a point; separate continuous segments retain all their original values, +timestamps and common-window coordinates. Independently sampled series must +not imply simultaneous observations or a continuous trend through absent data. + +A visible, SVG-described explanation names the affected configured series and +states the limit: missing readings do not identify an outage's cause or duration. +There is no elapsed-time/cadence heuristic, synthetic zero, invented observation, +new API field or collector change. Times outside the configured panel cannot +split its series. Entirely absent series retain the existing missing/fallback +policy. Live or retained snapshot carriers cannot repair a stored gap; only a +matching stored response can. Source/range/access isolation, transient retained +reads and exact-time keyboard/pointer/touch inspection remain in force. + +`GuestDrawerHistory.gaps.test.tsx` and `guestHistorySegments.test.ts` cover +split lines, singleton recovery, zero rates, interleaved sampling, multiple +segments, configured-group isolation, invalid points, elapsed-time controls, +live updates, stored refresh and denied/transient reads. `GuestDrawer.test.tsx` +checks the full drawer's live-state recovery and unchanged request budget. +Updated pointer/sparse +regressions preserve their original missing-value and fallback obligations. +`browser-tests/history-observation-gaps.cjs` exercises the production full guest +drawer with synthetic APIs in Chromium/WebKit desktop and 390/320-pixel light +and dark views. This is presentation acceptance, not native backup/QGA safety, +provider outage attribution, installed recovery or release availability. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index a6bbfb099d..1a49ebcb1e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1690,6 +1690,15 @@ without contention or cross-delivery. lifecycle. Request handlers may reuse the cached manager but must not reopen or remigrate SQLite on each role or assignment request, and shutdown must close the owned manager set. +8. Guest Overview filesystem provenance is local presentation over the already + supplied snapshot: no additional query, timer, poll, History fetch or guest + command may be added to establish it. Its existing linear filesystem mapping + may label retained values and omit current utilization bars; it must preserve + ordinary current-value builder behaviour and must not mutate the snapshot. + `DiskList.test.tsx` verifies the pure builder's retained/unavailable/current + controls; mounted drawer regressions and the full-drawer browser proof cover + the shipped path. No performance or native workload improvement follows from + the presentation proof alone. ## Current State @@ -3671,3 +3680,17 @@ backend, metrics-store, performance threshold or latency claim changes here. including selection retention while a same-source window advances. Browser receipts exercise the actual PBS table/drawer renderer with synthetic APIs, not installed collection or a performance benchmark. + +### Stored drawer History segmentation stays bounded + +Missing-observation rendering uses the existing normalized, configured-group +timestamps and the unchanged maximum of 240 requested points per metric. The +segment builder indexes those timestamps once per series and walks its stored +points without nested nearest-point searches. It adds no request, polling timer, +collector or synthetic reading. Separate singletons/segments retain the source +data and shared time window; live updates cannot fill absent stored evidence. +The mounted `GuestDrawerHistory.gaps.test.tsx` controls pin one initial read and +only explicit matching refreshes; the full `GuestDrawer.test.tsx` pins the +unchanged cap and no live-recovery read. `guestHistorySegments.test.ts` pins +complete point preservation at the existing request bound. Neither claims a +timing benchmark, fleet CPU attribution or native incident relief. 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 @@ + + +
+ + +Synthetic guest responses. No guest command is sent.
+Synthetic observations. No guest check is sent.
++ Synthetic readings. No backup or guest-agent command is sent. +
+ +Discovery Failed
++ Discovery did not complete +
{scanError()}