From 19fa6b1c8dffacd10c0a9dbf025b039844eebbce Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:16:44 +0100 Subject: [PATCH 1/4] Make TrueNAS diagnostics safe and distinguish missing telemetry Replace token and API-key shell arguments with private header and payload files, verify the exact test/save recipes with synthetic credentials, and separate hardware capacity from actual usage and History. Bound local logs and keep sanitized evidence and trusted HTTPS as the troubleshooting path. Contract-Neutral: Documentation and synthetic recipe checks only; no runtime, API, transport or metric contract changes. Change-source: pulse-maintainer --- docs/TRUENAS.md | 85 +++++++++++--- frontend-modern/public/docs/TRUENAS.md | 85 +++++++++++--- scripts/tests/test_truenas_docs.py | 149 +++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 scripts/tests/test_truenas_docs.py diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 13ef48257b..80dbb83387 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -89,24 +89,51 @@ All endpoints require admin authentication. | `DELETE` | `/api/truenas/connections/{id}` | Remove a TrueNAS connection | | `POST` | `/api/truenas/connections/test` | Test a connection before saving | -### Adding a connection (API) +### Testing and adding a connection (API) + +The **Settings → TrueNAS** 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. + +Create or protect the connection file before opening it in a trusted editor: ```bash -curl -X POST http://localhost:7655/api/truenas/connections \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"nas-1","host":"https://truenas.local","username":"key-owner","apiKey":"your-api-key"}' +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" ``` -### Testing a connection (API) +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. + +Test the connection before saving it: ```bash -curl -X POST http://localhost:7655/api/truenas/connections/test \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"nas-1","host":"https://truenas.local","username":"key-owner","apiKey":"your-api-key"}' +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 ``` +After checking the test response, save the connection with: + +```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 +``` + +These Pulse URLs are loopback-only examples. For a remote Pulse server, use +its trusted HTTPS origin without disabling certificate verification. + ## Troubleshooting ### "TrueNAS service unavailable" @@ -122,21 +149,43 @@ curl -X POST http://localhost:7655/api/truenas/connections/test \ ### No data appearing after adding connection - Wait at least 30 seconds for the first poll cycle. -- Check Pulse logs for TrueNAS-related errors: +- **Test Connection** checks authentication and the selected transport, not + successful collection of every metric. The legacy REST diagnostic is + expected for recognized CORE 13 systems; it is not itself a connection error. +- Inspect a bounded local log excerpt for TrueNAS-related errors: ```bash - journalctl -u pulse | grep -i truenas + journalctl -u pulse -n 100 --no-pager | grep -i truenas # or - docker logs pulse | grep -i truenas + docker logs --tail 100 pulse 2>&1 | grep -i truenas ``` +- If collection is safe, **Settings → Diagnostics → Export for GitHub + (sanitized)** can provide connection evidence. Review the export or log + excerpt before sharing: remove credentials, cookies, private hostnames, + addresses and webhook URLs. Do not upload configuration or credential files. + +### Inventory works but CPU, memory or History is missing + +Services, uptime, hardware capacity and disk temperatures can be collected +without CPU or memory usage. A Host panel showing core count and total memory +does not establish a live reading; check the CPU/memory usage columns and +History series separately. A disk-utilisation line does not establish that +CPU, memory or network History is available. + +Check whether the corresponding graphs work in the TrueNAS web UI. If Pulse +still shows missing readings, report the affected panels, running Pulse and +TrueNAS versions, and the relevant sanitized evidence above. Do not replace +an otherwise working key or clear stored History merely because a graph is +empty. When sharing a requested native graph response, include only its graph +name, `legend`, timing fields and one `data` row or error; omit authentication +messages and identifying details. Do not upload a full browser network capture. ### Stale TrueNAS data - If TrueNAS data stops updating, the source status transitions to `stale` after ~120 seconds. - Check TrueNAS connectivity and API key validity. -- Verify with the API: - ```bash - curl -H "Authorization: Bearer $TOKEN" http://localhost:7655/api/resources \ - | jq '.resources[] | select(.platformType == "truenas")' - ``` +- In your signed-in Pulse browser session, open `/api/resources` on the same + Pulse origin and inspect the entries with `platformType: "truenas"`. This + uses your existing session without copying a token or cookie. Do not post + the full response; use the sanitized export or a reviewed relevant excerpt. ### Disabling TrueNAS integration Set `PULSE_ENABLE_TRUENAS=false` and restart Pulse. Existing connection data is preserved but polling stops. diff --git a/frontend-modern/public/docs/TRUENAS.md b/frontend-modern/public/docs/TRUENAS.md index 13ef48257b..80dbb83387 100644 --- a/frontend-modern/public/docs/TRUENAS.md +++ b/frontend-modern/public/docs/TRUENAS.md @@ -89,24 +89,51 @@ All endpoints require admin authentication. | `DELETE` | `/api/truenas/connections/{id}` | Remove a TrueNAS connection | | `POST` | `/api/truenas/connections/test` | Test a connection before saving | -### Adding a connection (API) +### Testing and adding a connection (API) + +The **Settings → TrueNAS** 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. + +Create or protect the connection file before opening it in a trusted editor: ```bash -curl -X POST http://localhost:7655/api/truenas/connections \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"nas-1","host":"https://truenas.local","username":"key-owner","apiKey":"your-api-key"}' +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" ``` -### Testing a connection (API) +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. + +Test the connection before saving it: ```bash -curl -X POST http://localhost:7655/api/truenas/connections/test \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"nas-1","host":"https://truenas.local","username":"key-owner","apiKey":"your-api-key"}' +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 ``` +After checking the test response, save the connection with: + +```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 +``` + +These Pulse URLs are loopback-only examples. For a remote Pulse server, use +its trusted HTTPS origin without disabling certificate verification. + ## Troubleshooting ### "TrueNAS service unavailable" @@ -122,21 +149,43 @@ curl -X POST http://localhost:7655/api/truenas/connections/test \ ### No data appearing after adding connection - Wait at least 30 seconds for the first poll cycle. -- Check Pulse logs for TrueNAS-related errors: +- **Test Connection** checks authentication and the selected transport, not + successful collection of every metric. The legacy REST diagnostic is + expected for recognized CORE 13 systems; it is not itself a connection error. +- Inspect a bounded local log excerpt for TrueNAS-related errors: ```bash - journalctl -u pulse | grep -i truenas + journalctl -u pulse -n 100 --no-pager | grep -i truenas # or - docker logs pulse | grep -i truenas + docker logs --tail 100 pulse 2>&1 | grep -i truenas ``` +- If collection is safe, **Settings → Diagnostics → Export for GitHub + (sanitized)** can provide connection evidence. Review the export or log + excerpt before sharing: remove credentials, cookies, private hostnames, + addresses and webhook URLs. Do not upload configuration or credential files. + +### Inventory works but CPU, memory or History is missing + +Services, uptime, hardware capacity and disk temperatures can be collected +without CPU or memory usage. A Host panel showing core count and total memory +does not establish a live reading; check the CPU/memory usage columns and +History series separately. A disk-utilisation line does not establish that +CPU, memory or network History is available. + +Check whether the corresponding graphs work in the TrueNAS web UI. If Pulse +still shows missing readings, report the affected panels, running Pulse and +TrueNAS versions, and the relevant sanitized evidence above. Do not replace +an otherwise working key or clear stored History merely because a graph is +empty. When sharing a requested native graph response, include only its graph +name, `legend`, timing fields and one `data` row or error; omit authentication +messages and identifying details. Do not upload a full browser network capture. ### Stale TrueNAS data - If TrueNAS data stops updating, the source status transitions to `stale` after ~120 seconds. - Check TrueNAS connectivity and API key validity. -- Verify with the API: - ```bash - curl -H "Authorization: Bearer $TOKEN" http://localhost:7655/api/resources \ - | jq '.resources[] | select(.platformType == "truenas")' - ``` +- In your signed-in Pulse browser session, open `/api/resources` on the same + Pulse origin and inspect the entries with `platformType: "truenas"`. This + uses your existing session without copying a token or cookie. Do not post + the full response; use the sanitized export or a reviewed relevant excerpt. ### Disabling TrueNAS integration Set `PULSE_ENABLE_TRUENAS=false` and restart Pulse. Existing connection data is preserved but polling stops. diff --git a/scripts/tests/test_truenas_docs.py b/scripts/tests/test_truenas_docs.py new file mode 100644 index 0000000000..d0dfd83c90 --- /dev/null +++ b/scripts/tests/test_truenas_docs.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Exercise TrueNAS connection recipes without putting credentials in argv. + +All credentials are synthetic. HTTP checks require the offline source-proof VM. +""" + +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import tempfile +import threading +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +DOC = ROOT / "docs/TRUENAS.md" +TOKEN = "synthetic-pulse-token" +NAS_KEY = "synthetic-truenas-key" +PAYLOAD = {"name": "test-nas", "host": "https://nas.example.invalid", + "username": "key-owner", "apiKey": NAS_KEY} + + +def commands(): + blocks = re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL) + preparation = next(block for block in blocks if "umask 077" in block) + requests = [block for block in blocks if block.startswith("curl ")] + return preparation, requests + + +@contextmanager +def server(status=200): + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers["Content-Length"])) + requests.append((self.path, dict(self.headers), json.loads(body))) + self.send_response(status) + self.end_headers() + self.wfile.write(b'{"fixture":true}\n') + + def log_message(self, *_args): + pass + + http = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=http.serve_forever, daemon=True) + thread.start() + try: + yield http.server_address[1], requests + finally: + http.shutdown() + http.server_close() + thread.join(timeout=5) + + +class TrueNASDocsTest(unittest.TestCase): + def test_shipped_copy_and_safe_diagnostics(self): + self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/TRUENAS.md").read_bytes()) + text = DOC.read_text() + bash = "\n".join(re.findall(r"```bash\n(.*?)```", text, re.DOTALL)) + self.assertNotRegex(bash, r"Authorization:|Bearer\s|\$TOKEN|apiKey|--insecure") + self.assertIn("journalctl -u pulse -n 100 --no-pager", bash) + self.assertIn("docker logs --tail 100 pulse 2>&1", bash) + self.assertIn("does not establish a live reading", text) + self.assertIn("Do not upload a full browser network capture", text) + + def test_preparation_protects_new_and_existing_payload(self): + preparation, _ = commands() + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + tools = home / "tools" + tools.mkdir() + editor = tools / "vi" + editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ "$1" = "$HOME/.config/pulse/truenas-connection.json" ]\n') + editor.chmod(0o700) + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}") + payload = home / ".config/pulse/truenas-connection.json" + for existing in (False, True): + if existing: + payload.write_text(json.dumps(PAYLOAD)) + payload.chmod(0o644) + subprocess.run(["bash", "-eu", "-c", preparation], env=env, check=True) + self.assertEqual(stat.S_IMODE(payload.stat().st_mode), 0o600) + self.assertEqual(payload.read_text(), json.dumps(PAYLOAD) if existing else "") + + def request(self, home, command, port): + private = home / ".config/pulse" + private.mkdir(parents=True, exist_ok=True) + for name, content in (("api-header", f"X-API-Token: {TOKEN}\n"), + ("truenas-connection.json", json.dumps(PAYLOAD))): + path = private / name + path.write_text(content) + path.chmod(0o600) + real_curl = shutil.which("curl") + self.assertIsNotNone(real_curl) + tools = home / "tools" + tools.mkdir(exist_ok=True) + recorder = tools / "curl" + recorder.write_text( + "#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n" + "Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n" + "os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n" + ) + recorder.chmod(0o700) + receipt = home / "argv.json" + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}", + REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt)) + command = command.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}") + result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10) + argv = json.loads(receipt.read_text()) + self.assertIn("@" + str(private / "api-header"), argv) + self.assertIn("@" + str(private / "truenas-connection.json"), argv) + for secret in (TOKEN, NAS_KEY): + self.assertNotIn(secret, " ".join(argv)) + self.assertNotIn(secret.encode(), result.stdout + result.stderr) + return result + + def test_test_and_save_send_header_and_json_from_private_files(self): + _, recipes = commands() + self.assertEqual(len(recipes), 2) + with tempfile.TemporaryDirectory() as temporary, server() as (port, requests): + for command, endpoint in zip(recipes, ("/api/truenas/connections/test", "/api/truenas/connections")): + result = self.request(Path(temporary), command, port) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + path, headers, payload = requests[-1] + self.assertEqual(path, endpoint) + self.assertEqual(headers["X-API-Token"], TOKEN) + self.assertEqual(headers["Content-Type"], "application/json") + self.assertEqual(payload, PAYLOAD) + + def test_http_auth_failure_is_not_reported_as_success(self): + _, recipes = commands() + for status in (401, 403): + for command in recipes: + with self.subTest(status=status, command=command), tempfile.TemporaryDirectory() as temporary: + with server(status) as (port, _): + result = self.request(Path(temporary), command, port) + self.assertEqual(result.returncode, 22, result.stderr.decode()) + self.assertIn(b'{"fixture":true}', result.stdout) + + +if __name__ == "__main__": + unittest.main() From 5e886b04d67fbf9edf555d7379db932d28117c3b Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:45:12 +0100 Subject: [PATCH 2/4] Keep drawer History on one dated time window Use the fulfilled API window and a common observed envelope across metric groups so sparse samples cannot look like a full selected range. Show dated endpoints, retain them with matching failed-refresh data, and clear them on range replacement. Preserve edge observations and reject non-date geometry. Pin mounted/model regressions, update both affected contracts, and retain production PBS drawer browser proof with its installed-acceptance limits. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 26 ++ .../subsystems/performance-and-scalability.md | 17 + .../browser-tests/history-window.cjs | 393 ++++++++++++++++++ frontend-modern/browser-verification.json | 227 +++++++--- .../components/Workloads/GuestDrawer.test.tsx | 21 + .../Workloads/GuestDrawerHistory.tsx | 45 +- .../GuestDrawerHistory.window.test.tsx | 243 +++++++++++ .../components/Workloads/guestDrawerModel.ts | 37 +- 8 files changed, 939 insertions(+), 70 deletions(-) create mode 100644 frontend-modern/browser-tests/history-window.cjs create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 65006e0435..c3bac1b5c9 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7597,3 +7597,29 @@ series, zero rates, single observations, ties, focus precedence and replacement. in Chromium/Firefox desktop and Chromium/WebKit phone emulation in both themes, including failed refreshes and source replacement. Scripted APIs establish presentation accuracy, not real PBS collection or installed #1723 acceptance. + +### Drawer History panels share a dated time window + +`GuestDrawerHistory` uses one common time interval for every configured metric +group, including utilization, network, disk I/O and thermals. The fulfilled +API response's valid `start`/`end` interval remains in view even if it contains +only a few minutes of observations. A sample at a given timestamp occupies the +same horizontal position in every panel; a group's sparse coverage must not +stretch independently to fill the selected range. Visible date/time endpoints +and full local timestamps in their accessible labels distinguish overnight and +multi-day windows. Native inspection still selects actual observations. + +Returned edge observations widen the common envelope instead of being clipped +or discarded, including aggregated bucket timestamps. An invalid API interval +falls back to the valid observed envelope across configured groups, not a +fabricated range. Non-date timestamps and unconfigured metrics cannot poison +that geometry. A valid empty window has labelled endpoints but no trend or +inspection control. Failed matching refreshes retain the window with its data; +target/range replacement clears both until matching data arrives. + +`GuestDrawerHistory.window.test.tsx` pins geometry, labels, refresh/replacement, +empty/invalid windows and edge observations. `browser-tests/history-window.cjs` +uses the production PBS table, resource drawers, History query and CSS with +synthetic APIs, checking three separately mapped drawers and range/refresh +behaviour in desktop and phone-emulated engines. This is presentation proof, +not installed PBS/VirtualBox collection or a complete #1723 acceptance result. 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 e027d59a6f..ed0ab8c5ec 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3360,3 +3360,20 @@ reproduced the CI magnitude or establishes an improvement. Keep the signal open as an environment-bound observation under the existing `performance-post-rc-headroom` follow-up; no chart-path code change is justified by this comparison alone. + +### Shared drawer History window calculation + +The drawer model computes bounds from the requested API interval and supplied +valid observation timestamps. The renderer derives one envelope per fulfilled +query from its configured metric groups and shares it across all panels; it +does not synthesize, resample or fetch extra points to fill an empty window. +Invalid dates cannot enter the labelled geometry, and returned edge buckets +remain inside the common envelope. The existing 240-point request cap per +metric, 30-second poll and retained-query cache limits are unchanged. No +backend, metrics-store, performance threshold or latency claim changes here. + +`GuestDrawer.test.tsx` verifies requested, expanded, invalid and absent bounds; +`GuestDrawerHistory.window.test.tsx` verifies the mounted query and geometry, +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. diff --git a/frontend-modern/browser-tests/history-window.cjs b/frontend-modern/browser-tests/history-window.cjs new file mode 100644 index 0000000000..ea0a9bce7e --- /dev/null +++ b/frontend-modern/browser-tests/history-window.cjs @@ -0,0 +1,393 @@ +// Production PBS table/drawers/History with synthetic APIs, not installed collection. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, firefox, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const engine = + process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'chromium'; + assert.ok(['chromium', 'firefox', 'webkit'].includes(engine)); + const width = process.argv.includes('--phone') ? 390 : 1365; + const baseline = process.argv.includes('--baseline'); + const requestedTheme = process.argv.find((arg) => arg.startsWith('--theme='))?.split('=')[1]; + assert.ok(!requestedTheme || ['light', 'dark'].includes(requestedTheme)); + const artifacts = path.join( + root, + 'node_modules', + `history-window-${engine}-${width}${baseline ? '-base' : ''}${requestedTheme ? `-${requestedTheme}` : ''}`, + ); + fs.mkdirSync(artifacts, { recursive: true }); + fs.chmodSync(artifacts, 0o755); + const progress = (stage, detail = {}) => { + const record = { stage, ...detail, at: new Date().toISOString() }; + fs.writeFileSync(path.join(artifacts, 'progress.json'), JSON.stringify(record) + '\n', { + mode: 0o644, + }); + console.log(JSON.stringify(record)); + }; + 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: fs.mkdtempSync(path.join(artifacts, 'vite-cache-')), + server: { host: '127.0.0.1', port: 5225, strictPort: true }, + }); + const sourceHashes = Object.fromEntries( + [ + 'src/components/Workloads/GuestDrawerHistory.tsx', + 'src/components/Workloads/guestDrawerModel.ts', + ].map((file) => [ + file, + createHash('sha256') + .update(fs.readFileSync(path.join(root, file))) + .digest('hex'), + ]), + ); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + let browser; + const observations = []; + try { + await server.listen(); + browser = await { chromium, firefox, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const theme of requestedTheme ? [requestedTheme] : ['light', 'dark']) { + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + locale: 'en-GB', + timezoneId: 'UTC', + ...(width === 390 ? { isMobile: true, hasTouch: true } : {}), + }); + page.setDefaultTimeout(25_000); + const requests = [], + errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + let state = 'success', + advance = 0, + held; + const hour = 3_600_000; + const end = Date.UTC(2026, 9, 1, 12); + const durations = { '1h': hour, '6h': 6 * hour, '24h': 24 * hour, '7d': 7 * 24 * hour }; + const point = (minutes, value) => ({ + timestamp: end - minutes * 60_000, + value, + min: value, + max: value, + }); + const response = (type, id, range, empty = false) => ({ + resourceType: type, + resourceId: id, + range, + start: end + advance - durations[range], + end: end + advance, + source: 'store', + metrics: empty + ? {} + : Object.fromEntries( + Object.entries({ + cpu: [ + point(10, id.endsWith('one') ? 21 : id.endsWith('two') ? 31 : 41), + point(5, 45), + ], + memory: [point(5, 55)], + disk: [point(8, 20), point(4, 30)], + netin: [point(60, 0), point(5, 2048)], + netout: [point(5, 4096)], + diskread: [point(2, 1024), point(0, 2048)], + diskwrite: [point(5, 0), point(0, 0)], + temperature: [point(5, 50)], + }).map(([metric, points]) => [ + metric, + points.filter( + (point) => + point.timestamp >= end + advance - durations[range] && + point.timestamp <= end + advance, + ), + ]), + ), + }); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5225') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const type = url.searchParams.get('resourceType'), + id = url.searchParams.get('resourceId'), + range = url.searchParams.get('range'); + assert.ok( + ['vm-one', 'vm-two', 'agent-three'].includes(id), + `uncorroborated target ${id}`, + ); + assert.ok(durations[range], `unexpected or locked read ${range}`); + requests.push({ type, id, range, state, advance, method: route.request().method() }); + if (state === 'hold') { + held = { route, type, id, range }; + return; + } + if (state === 'failure') + return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } }); + return route.fulfill({ json: response(type, id, range, state === 'empty') }); + } + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community', label: 'Pulse Community runtime' }, + blocked_capabilities: [], + }, + }); + return route.fulfill({ json: { data: [], enabled: false } }); + }); + progress('loading-production-table', { theme }); + await page.goto('http://127.0.0.1:5225/browser-tests/pbs-identity-boundary.html', { + waitUntil: 'domcontentloaded', + timeout: 120_000, + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + await page.getByRole('button', { name: 'Corroborate links', exact: true }).click(); + progress('linked-table-ready', { theme }); + const xs = async (plot) => + [ + ...(await plot.locator('path').first().getAttribute('d')).matchAll(/[ML](-?[\d.]+),/g), + ].map((match) => Number(match[1])); + const expectedX = (minutes, range) => + 34 + ((durations[range] - advance - minutes * 60_000) / durations[range]) * 318; + const verifyWindow = async (detail, range) => { + const axes = detail.getByTestId('guest-history-time-window'); + const groups = await detail.getByTestId('guest-history-group-chart').count(); + await axes.first().waitFor(); + assert.equal(await axes.count(), groups); + for (const axis of await axes.all()) { + const times = axis.locator('time'); + assert.equal( + await times.nth(0).getAttribute('datetime'), + new Date(end + advance - durations[range]).toISOString(), + ); + assert.equal( + await times.nth(1).getAttribute('datetime'), + new Date(end + advance).toISOString(), + ); + assert.match(await times.nth(0).getAttribute('aria-label'), /^Window start: /); + assert.match(await times.nth(1).getAttribute('aria-label'), /^Window end: /); + assert.notEqual(await times.nth(0).innerText(), await times.nth(1).innerText()); + } + }; + let detail; + for (const suffix of baseline ? ['three'] : ['one', 'two', 'three']) { + const toggle = page.getByRole('button', { + name: `Expand details for backup-connection-${suffix}`, + exact: true, + }); + // WebKit phone uses the native keyboard disclosure; its full-row touch + // opening was not established. Touch inspection is exercised below. + if (width === 1365 || engine === 'webkit') { + await toggle.focus(); + await page.keyboard.press('Enter'); + } else await page.locator(`td[title="backup-connection-${suffix} · tank"]`).tap(); + assert.equal( + await page + .getByRole('button', { + name: `Collapse details for backup-connection-${suffix}`, + exact: true, + }) + .getAttribute('aria-expanded'), + 'true', + ); + detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`); + await detail.getByRole('tab', { name: 'History', exact: true }).click(); + const utilization = detail.locator('[data-history-group="utilization"]'); + const plot = utilization.getByTestId('guest-history-plot'); + await plot.locator('path').first().waitFor(); + const observedXs = await xs(plot); + const observed = { + suffix, + theme, + observedXs, + expectedXs: [expectedX(10, '24h'), expectedX(5, '24h')], + axes: await detail.getByTestId('guest-history-time-window').count(), + }; + console.log(JSON.stringify({ timeWindowObservation: observed, sourceHashes })); + await detail.screenshot({ path: path.join(artifacts, `${suffix}-24h-${theme}.png`) }); + assert.ok(Math.abs(observedXs[0] - expectedX(10, '24h')) < 0.01, JSON.stringify(observed)); + assert.ok(Math.abs(observedXs[1] - expectedX(5, '24h')) < 0.01, JSON.stringify(observed)); + await verifyWindow(detail, '24h'); + assert.ok( + requests.some( + (request) => request.id === (suffix === 'three' ? 'agent-three' : `vm-${suffix}`), + ), + ); + const dims = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + width: innerWidth, + })); + assert.ok(dims.scroll <= dims.width + 1, JSON.stringify(dims)); + observations.push({ ...observed, dims }); + progress('drawer-verified', { suffix, theme }); + } + const utilization = detail.locator('[data-history-group="utilization"]'); + const slider = utilization.getByRole('slider', { name: 'Inspect Utilization history' }); + const network = detail.locator('[data-history-group="network"]'); + const networkSlider = network.getByRole('slider', { name: 'Inspect Network I/O history' }); + const readsBefore = requests.length; + await slider.focus(); + await slider.press('Home'); + await slider.press('ArrowRight'); + await slider.press('ArrowRight'); + assert.match( + await slider.getAttribute('aria-valuetext'), + /01\/10\/2026, 11:55:00\. CPU 45\.0%\. Memory 55\.0%\. Disk no observation/, + ); + const utilizationX = await utilization + .getByTestId('guest-history-plot') + .locator('circle[r="3"]') + .first() + .getAttribute('cx'); + await networkSlider.focus(); + await networkSlider.press('End'); + const networkX = await network + .getByTestId('guest-history-plot') + .locator('circle[r="3"]') + .first() + .getAttribute('cx'); + assert.equal(networkX, utilizationX); + assert.match( + (await network.innerText()).replace(/\s+/g, ' '), + /In\s*2\.00 KB\/s Out\s*4\.00 KB\/s/, + ); + if (width === 390) { + await networkSlider.scrollIntoViewIfNeeded(); + const box = await networkSlider.boundingBox(); + assert.ok(box.height >= 44); + await page.touchscreen.tap(box.x + 3, box.y + box.height / 2); + assert.match( + await networkSlider.getAttribute('aria-valuetext'), + /In 0 B\/s\. Out no observation/, + ); + } + assert.equal(requests.length, readsBefore, 'inspection must not request metrics'); + progress('inspection-verified', { theme }); + await networkSlider.press('Tab'); + state = 'failure'; + await detail.getByRole('button', { name: 'Refresh history' }).click(); + await detail + .getByText('History refresh failed. Showing previously loaded history.') + .waitFor(); + await verifyWindow(detail, '24h'); + assert.ok( + Math.abs( + (await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '24h'), + ) < 0.01, + ); + assert.ok(!(await detail.innerText()).includes('Private fixture detail')); + await detail.screenshot({ path: path.join(artifacts, `retained-window-${theme}.png`) }); + progress('retained-window-verified', { theme }); + advance = 10 * 60_000; + state = 'success'; + await detail.getByRole('button', { name: 'Retry history' }).click(); + await detail.getByRole('button', { name: 'Refresh history' }).waitFor(); + await page.waitForFunction( + (expected) => + document + .querySelector('[data-testid="guest-history-time-window"] time:last-child') + ?.getAttribute('datetime') === expected, + new Date(end + advance).toISOString(), + ); + await verifyWindow(detail, '24h'); + assert.ok( + Math.abs( + (await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '24h'), + ) < 0.01, + ); + // Source/range replacement must clear the old window as well as its samples. + state = 'hold'; + progress('refreshed-window-verified', { theme }); + await detail.getByRole('combobox', { name: 'History range' }).selectOption('1h'); + await detail.getByText('Loading history', { exact: true }).first().waitFor(); + assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0); + assert.equal(await detail.getByTestId('guest-history-plot').locator('path').count(), 0); + assert.equal(await detail.getByRole('slider').count(), 0); + assert.ok(held); + state = 'success'; + await held.route.fulfill({ json: response(held.type, held.id, held.range) }); + await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor(); + await verifyWindow(detail, '1h'); + assert.ok( + Math.abs( + (await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '1h'), + ) < 0.01, + ); + await detail.screenshot({ path: path.join(artifacts, `one-hour-${theme}.png`) }); + progress('replacement-window-verified', { theme }); + state = 'empty'; + await detail.getByRole('button', { name: 'Refresh history' }).click(); + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 0, + ); + await verifyWindow(detail, '1h'); + assert.equal(await detail.getByRole('slider').count(), 0); + assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 4); + state = 'success'; + await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d'); + await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor(); + await verifyWindow(detail, '7d'); + const readsBeforeLock = requests.length; + await detail.getByRole('combobox', { name: 'History range' }).selectOption('14d'); + await detail.getByText(/14 days history requires a higher license plan/).waitFor(); + assert.equal(requests.length, readsBeforeLock); + assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0); + assert.equal(errors.length, 0, errors.join('\n')); + observations.push({ theme, requests, errors, utilizationX, networkX }); + progress('completed-theme', { theme }); + await page.close(); + } + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify( + { + engine, + width, + browserVersion: browser.version(), + playwrightVersion, + sourceHashes, + observations, + }, + null, + 2, + ) + '\n', + ); + console.log( + JSON.stringify({ + result: 'passed', + engine, + width, + browserVersion: browser.version(), + sourceHashes, + cases: observations.length, + }), + ); + } finally { + await browser?.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 3454045d2f..6fd5437339 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,18 @@ { "version": 1, - "base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881", - "verified_at": "2026-10-01T00:37:51Z", + "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", + "verified_at": "2026-10-01T01:42:43Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99" }, "routes": [ - "/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)" + "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)" ], "viewports": [ { @@ -23,65 +25,174 @@ } ], "states": [ - "light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp", - "a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data", - "zero-valued lone observations can be inspected without a synthetic trend", - "genuinely aligned series share cursor geometry and their own observed values", - "network directions preserve independently missing and zero rates", - "native keyboard/touch selection takes precedence over pointer movement", - "failed same-source refresh retains correctly attributed observations and a visible warning", - "a successful refresh updates the hovered timestamp values without choosing other latest metrics", - "held service-target replacement has no former-host observations, cursor or dated pointer description" + "light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot", + "all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels", + "three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities", + "same-time observations occupy the same horizontal coordinate across groups with different sample coverage", + "failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window", + "held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window", + "empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read" ], "interactions": [ - "move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry", - "leave the plot to restore latest/current legends; verify pointer inspection makes no API reads", - "focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection", - "Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area", - "fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response" + "desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)", + "focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network", + "Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads", + "fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).", + "command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.", "browser_versions": { "chromium": "141.0.7390.37", "firefox": "142.0.1", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log" + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log" ], - "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability.", + "notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability.", "prior_verifications": [ + { + "version": 1, + "base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881", + "verified_at": "2026-10-01T00:37:51Z", + "result": "passed", + "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" + }, + "routes": [ + "/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp", + "a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data", + "zero-valued lone observations can be inspected without a synthetic trend", + "genuinely aligned series share cursor geometry and their own observed values", + "network directions preserve independently missing and zero rates", + "native keyboard/touch selection takes precedence over pointer movement", + "failed same-source refresh retains correctly attributed observations and a visible warning", + "a successful refresh updates the hovered timestamp values without choosing other latest metrics", + "held service-target replacement has no former-host observations, cursor or dated pointer description" + ], + "interactions": [ + "move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry", + "leave the plot to restore latest/current legends; verify pointer inspection makes no API reads", + "focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection", + "Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area", + "fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).", + "browser_versions": { + "chromium": "141.0.7390.37", + "firefox": "142.0.1", + "webkit": "26.0" + }, + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log" + ], + "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability." + }, { "version": 1, "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", "verified_at": "2026-09-30T23:28:26Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" - ], + "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" }, @@ -221,9 +332,7 @@ "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0", "verified_at": "2026-09-30T20:12:09Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" - ], + "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67" }, @@ -278,9 +387,7 @@ "base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764", "verified_at": "2026-09-30T01:40:13Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" - ], + "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], "content_sha256": { "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36" }, @@ -329,9 +436,7 @@ "base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6", "verified_at": "2026-09-29T23:04:22Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" - ], + "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], "content_sha256": { "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b" }, @@ -427,9 +532,7 @@ "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484", "verified_at": "2026-09-29T19:50:45Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/pages/Actions.tsx" - ], + "changed_paths": ["frontend-modern/src/pages/Actions.tsx"], "content_sha256": { "frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce" }, @@ -470,9 +573,7 @@ "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", "verified_at": "2026-09-29T19:09:52Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/features/actions/ActionReviewDialog.tsx" - ], + "changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"], "content_sha256": { "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" }, diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 4a0624e370..8b44aefcb1 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -16,9 +16,30 @@ import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeSt import guestDrawerSource from './GuestDrawer.tsx?raw'; import guestDrawerManageSource from './GuestDrawerManage.tsx?raw'; import guestDrawerOverviewSource from './GuestDrawerOverview.tsx?raw'; +import { getGuestDrawerHistoryRangeBounds } from './guestDrawerModel'; // ── Mocks ────────────────────────────────────────────────────────────── +describe('shared drawer History window geometry', () => { + it('preserves the requested interval and edge observations without manufacturing timestamps', () => { + const sample = (timestamp: number) => ({ timestamp, value: 0, min: 0, max: 0 }); + const series = [{ points: [sample(800), sample(900)] }, { points: [sample(300)] }]; + expect(getGuestDrawerHistoryRangeBounds(series, { start: 0, end: 1000 })).toEqual({ + startTime: 0, + endTime: 1000, + }); + expect(getGuestDrawerHistoryRangeBounds(series, { start: 500, end: 1000 })).toEqual({ + startTime: 300, + endTime: 1000, + }); + expect(getGuestDrawerHistoryRangeBounds(series, { start: 1000, end: 0 })).toEqual({ + startTime: 300, + endTime: 900, + }); + expect(getGuestDrawerHistoryRangeBounds([], { start: 0, end: 0 })).toBeNull(); + }); +}); + const chartsApiMocks = vi.hoisted(() => ({ getMetricsHistory: vi.fn(), })); diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 9e70e0dc82..6cf02dac9d 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -41,6 +41,7 @@ import { normalizeGuestDrawerHistoryPoints, type GuestDrawerHistoryGroupConfig, type GuestDrawerHistoryTarget, + type GuestDrawerHistoryTimeBounds, } from './guestDrawerModel'; interface GuestDrawerHistoryProps { @@ -68,6 +69,7 @@ interface GuestDrawerHistoryGroupChartProps { metrics: Record; range: HistoryTimeRange; sourceKey: string; + timeBounds: GuestDrawerHistoryTimeBounds | null; } const GUEST_DRAWER_HISTORY_MAX_POINTS = 240; @@ -273,7 +275,7 @@ const GuestDrawerHistoryGroupChart: Component }); const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2)); const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit)); - const bounds = createMemo(() => getGuestDrawerHistoryRangeBounds(series())); + const bounds = () => props.timeBounds; const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null); const hoveredSeries = createMemo(() => { const timestamp = activeTimestamp(); @@ -480,6 +482,36 @@ const GuestDrawerHistoryGroupChart: Component + + {(window) => ( +
+ + {(endpoint) => ( + + )} + +
+ )} +

{chartDescription()}

@@ -560,6 +592,16 @@ export const GuestDrawerHistory: Component = (props) => const metrics = createMemo(() => historyQuery.value().metrics ?? {}); const groups = createMemo(() => props.groups ?? GUEST_DRAWER_HISTORY_GROUPS); + const timeBounds = createMemo(() => + getGuestDrawerHistoryRangeBounds( + groups().flatMap((group) => + group.series.map((series) => ({ + points: normalizeGuestDrawerHistoryPoints(metrics()[series.metric], series.unit), + })), + ), + historyQuery.value(), + ), + ); const hasHistoryPoints = createMemo(() => groups().some((group) => group.series.some( @@ -628,6 +670,7 @@ export const GuestDrawerHistory: Component = (props) => currentMetrics={props.currentMetrics} range={props.range} sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`} + timeBounds={timeBounds()} /> )} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx new file mode 100644 index 0000000000..cdcdde7979 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx @@ -0,0 +1,243 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChartsAPI, type AllMetricsHistoryResponse, type HistoryTimeRange } from '@/api/charts'; +import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import { GuestDrawerHistory } from '../GuestDrawerHistory'; +import { + getGuestDrawerHistoryRangeBounds, + normalizeGuestDrawerHistoryPoints, +} from '../guestDrawerModel'; + +vi.mock('@/stores/license', () => ({ + loadRuntimeCapabilities: vi.fn(async () => undefined), + maxHistoryDays: () => 7, + isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range), +})); + +afterEach(() => { + cleanup(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +const start = Date.UTC(2026, 8, 30, 12); +const hour = 3_600_000; +const end = start + 24 * hour; +const target = { resourceType: 'agent' as const, resourceId: 'pbs-host' }; +const point = (timestamp: number, value: number) => ({ timestamp, value, min: value, max: value }); +const response = ( + overrides: Partial = {}, +): AllMetricsHistoryResponse => ({ + ...target, + range: '24h', + start, + end, + source: 'store', + metrics: { + cpu: [point(end - 10 * 60_000, 12), point(end - 5 * 60_000, 25)], + memory: [point(end - 5 * 60_000, 40)], + netin: [point(end - hour, 0), point(end - 5 * 60_000, 2048)], + diskwrite: [point(end - 5 * 60_000, 0), point(end, 0)], + temperature: [point(end - 5 * 60_000, 50)], + }, + ...overrides, +}); +const mount = () => + render(() => ( + + )); +const group = (id: string) => + screen + .getAllByTestId('guest-history-group-chart') + .find((chart) => chart.dataset.historyGroup === id)!; +const pathXs = (chart: HTMLElement) => + [ + ...chart + .querySelector('path')! + .getAttribute('d')! + .matchAll(/[ML](-?[\d.]+),/g), + ].map((match) => Number(match[1])); +const expectedX = (timestamp: number, first = start, last = end) => + 34 + ((timestamp - first) / Math.max(1, last - first)) * 318; +const axis = (chart: HTMLElement) => within(chart).getByTestId('guest-history-time-window'); +const selectSharedTime = (chart: HTMLElement) => { + const slider = within(chart).getByRole('slider'); + slider.focus(); + fireEvent.input(slider, { target: { value: '1' } }); + return chart.querySelector('circle[r="3"]')!; +}; + +describe('GuestDrawerHistory shared time window', () => { + it('does not stretch five minutes of stored CPU across a 24-hour query', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const xs = pathXs(group('utilization')); + expect(xs[0]).toBeCloseTo(expectedX(end - 10 * 60_000), 2); + expect(xs[1]).toBeCloseTo(expectedX(end - 5 * 60_000), 2); + expect(xs[1] - xs[0]).toBeLessThan(2); + }); + + it('places the same observation time at the same x in panels with different sample coverage', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(); + await screen.findByRole('slider', { name: 'Inspect Network I/O history' }); + const utilizationX = selectSharedTime(group('utilization')).getAttribute('cx'); + const networkX = selectSharedTime(group('network')).getAttribute('cx'); + expect(networkX).toBe(utilizationX); + expect(Number(networkX)).toBeCloseTo(expectedX(end - 5 * 60_000)); + }); + + it('labels every panel with the full dated interval, including empty and lone-observation panels', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + for (const chart of screen.getAllByTestId('guest-history-group-chart')) { + const times = axis(chart).querySelectorAll('time'); + expect(times).toHaveLength(2); + expect(times[0]).toHaveAttribute('datetime', new Date(start).toISOString()); + expect(times[1]).toHaveAttribute('datetime', new Date(end).toISOString()); + expect(times[0]).toHaveAccessibleName(`Window start: ${new Date(start).toLocaleString()}`); + expect(times[1]).toHaveAccessibleName(`Window end: ${new Date(end).toLocaleString()}`); + expect(times[0].textContent).not.toBe(times[1].textContent); + } + }); + + it('retains the last fulfilled window with its data after a failed refresh', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(new Error('Private fixture detail')); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const before = group('utilization').querySelector('path')!.getAttribute('d'); + fireEvent.click(screen.getByRole('button', { name: 'Refresh history' })); + await screen.findByText('History refresh failed. Showing previously loaded history.'); + expect(group('utilization').querySelector('path')).toHaveAttribute('d', before); + expect(axis(group('utilization')).querySelector('time')).toHaveAttribute( + 'datetime', + new Date(start).toISOString(), + ); + expect(document.body).not.toHaveTextContent('Private fixture detail'); + }); + + it('moves the window on a successful poll without moving a selected observation to a different time', async () => { + vi.useFakeTimers(); + const shift = 10 * 60_000; + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce(response({ start: start + shift, end: end + shift })); + mount(); + await vi.advanceTimersByTimeAsync(0); + selectSharedTime(group('utilization')); + const valueText = within(group('utilization')) + .getByRole('slider') + .getAttribute('aria-valuetext'); + await vi.advanceTimersByTimeAsync(30_000); + expect(within(group('utilization')).getByRole('slider')).toHaveAttribute( + 'aria-valuetext', + valueText, + ); + expect( + Number(group('utilization').querySelector('circle[r="3"]')!.getAttribute('cx')), + ).toBeCloseTo(expectedX(end - 5 * 60_000, start + shift, end + shift)); + expect(axis(group('utilization')).querySelectorAll('time')[1]).toHaveAttribute( + 'datetime', + new Date(end + shift).toISOString(), + ); + }); + + it('does not keep former-range endpoints while a replacement read is pending', async () => { + let finish!: (value: AllMetricsHistoryResponse) => void; + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const [range, setRange] = createSignal('24h'); + render(() => ( + + )); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + setRange('1h'); + await waitFor(() => + expect(screen.queryAllByTestId('guest-history-time-window')).toHaveLength(0), + ); + expect(group('utilization').querySelectorAll('path')).toHaveLength(0); + finish(response({ range: '1h', start: end - hour })); + await waitFor(() => expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4)); + expect(pathXs(group('utilization'))[0]).toBeCloseTo(expectedX(end - 10 * 60_000, end - hour)); + }); + + it('keeps an empty valid query window without inventing observations or a trend', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ metrics: {} })); + mount(); + await waitFor(() => expect(screen.getAllByText('Collecting history')).toHaveLength(4)); + expect(screen.queryByRole('slider')).toBeNull(); + expect(document.querySelectorAll('path')).toHaveLength(0); + expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4); + }); + + it('falls back to one shared observed interval when the API window is invalid', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ start: NaN, end: Infinity }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + const xs = pathXs(group('utilization')); + expect(xs[0]).toBeCloseTo(expectedX(end - 10 * 60_000, end - hour)); + expect(axis(group('utilization')).querySelector('time')).toHaveAttribute( + 'datetime', + new Date(end - hour).toISOString(), + ); + expect(selectSharedTime(group('utilization')).getAttribute('cx')).toBe( + selectSharedTime(group('network')).getAttribute('cx'), + ); + }); + + it('encloses returned edge observations rather than clipping or discarding an aggregated bucket', async () => { + const edge = start - 5 * 60_000; + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + metrics: { ...response().metrics, cpu: [point(edge, 10), point(end - 5 * 60_000, 25)] }, + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(axis(group('network')).querySelector('time')).toHaveAttribute( + 'datetime', + new Date(edge).toISOString(), + ); + expect(pathXs(group('network'))[0]).toBeCloseTo(expectedX(end - hour, edge)); + expect(within(group('utilization')).getByRole('slider')).toHaveAttribute('max', '1'); + }); + + it('rejects non-dates before geometry and labels can be poisoned', () => { + expect(normalizeGuestDrawerHistoryPoints([point(9e15, 12), point(start, 0)], '%')).toEqual([ + point(start, 0), + ]); + expect( + getGuestDrawerHistoryRangeBounds([{ points: [point(9e15, 12), point(start, 0)] }]), + ).toEqual({ startTime: start, endTime: start }); + }); + + it('does not let an unconfigured metric change the visible panels time window', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + metrics: { ...response().metrics, unrelated: [point(end + 90 * 24 * hour, 10)] }, + }), + ); + mount(); + await screen.findByRole('slider', { name: 'Inspect Utilization history' }); + expect(axis(group('utilization')).querySelectorAll('time')[1]).toHaveAttribute( + 'datetime', + new Date(end).toISOString(), + ); + expect(pathXs(group('utilization'))[0]).toBeCloseTo(expectedX(end - 10 * 60_000), 2); + }); +}); diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 8299c05fa2..88fcafee6b 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -55,6 +55,11 @@ export interface GuestDrawerHistoryScale { maxValue: number; } +export interface GuestDrawerHistoryTimeBounds { + startTime: number; + endTime: number; +} + export interface GuestDrawerBackupPresentation { ageClass: string; ageLabel: string; @@ -157,12 +162,15 @@ const clampHistoryPointValue = (value: number, unit: string): number => { return unit === '%' ? Math.min(100, nonNegative) : nonNegative; }; +const isHistoryTimestamp = (timestamp: number): boolean => + Number.isFinite(timestamp) && Number.isFinite(new Date(timestamp).getTime()); + export const normalizeGuestDrawerHistoryPoints = ( points: AggregatedMetricPoint[] | undefined, unit: string, ): AggregatedMetricPoint[] => (points ?? []) - .filter((point) => Number.isFinite(point.timestamp) && Number.isFinite(point.value)) + .filter((point) => isHistoryTimestamp(point.timestamp) && Number.isFinite(point.value)) .map((point) => { const value = clampHistoryPointValue(point.value, unit); return { @@ -271,12 +279,29 @@ export const getGuestDrawerHistoryValueLabel = ( export const getGuestDrawerHistoryRangeBounds = ( groupedSeries: readonly { points: readonly AggregatedMetricPoint[] }[], -): { startTime: number; endTime: number } | null => { - const timestamps = groupedSeries.flatMap((item) => item.points.map((point) => point.timestamp)); - if (timestamps.length === 0) return null; + window?: { start: number; end: number }, +): GuestDrawerHistoryTimeBounds | null => { + const timestamps = groupedSeries + .flatMap((item) => item.points.map((point) => point.timestamp)) + .filter(isHistoryTimestamp); + const windowBounds = + window && + isHistoryTimestamp(window.start) && + isHistoryTimestamp(window.end) && + window.end > window.start + ? { startTime: window.start, endTime: window.end } + : null; + if (timestamps.length === 0) return windowBounds; + + // The API's requested window is shared across every panel. Do not stretch + // a few recent readings across the whole selected range, or scale each + // metric group to different times. Preserve returned edge observations + // (including aggregate bucket timestamps) by widening the common envelope. + const first = Math.min(...timestamps); + const last = Math.max(...timestamps); return { - startTime: Math.min(...timestamps), - endTime: Math.max(...timestamps), + startTime: Math.min(windowBounds?.startTime ?? first, first), + endTime: Math.max(windowBounds?.endTime ?? last, last), }; }; From 4101c37db548ca85ca8500f433b15a3c45fba2ab Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:56:58 +0100 Subject: [PATCH 3/4] Keep watchdog source observations separate from promotion gates The exact no-mutation watchdog failed at published 6.4.5 because the release line has pending repairs but still declares its stable VERSION. Observe that governed source and its preceding stable reference without inventing a new promotion, while keeping candidate resolver gates unchanged and watchdog artifacts out of promotion-readiness records. Change-source: pulse-maintainer --- .github/workflows/release-dry-run.yml | 67 +++++++++++--- .../subsystems/deployment-installability.md | 17 +++- .../installtests/build_release_assets_test.go | 31 +++++++ .../release_control/rehearsal_source_test.py | 91 ++++++++++++++++++- .../release_promotion_policy_test.py | 14 ++- 5 files changed, 201 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 3f46a4b58e..3ea8d6fd08 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -315,16 +315,33 @@ jobs: elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}") fi - if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then - # Both admitted watchdog paths have no candidate inputs. Ordinary - # candidate rehearsals retain their explicit-rollback requirement. - HELPER_ARGS+=(--derive-rollback-latest-stable) - echo "[OK] Scheduled rehearsal: deriving rollback target from the latest preceding stable tag" + if [ "${WATCHDOG_MODE}" = "true" ]; then + # A release line can retain its published stable VERSION while + # reviewed fixes accumulate. Observing that source is not a new + # stable promotion: do not invent an RC, soak or exception envelope. + # These helpers already exist on the selected release line; no + # newer main-only helper is required after the detached checkout. + WATCHED_VERSION="$VERSION" PYTHONPATH=scripts/release_control python3 - <<'PY' > "$RUNNER_TEMP/rehearsal-metadata.out" + import os + from resolve_release_promotion import ( + derive_latest_stable_rollback_tag, list_stable_tags, release_stage, tag_exists, + ) + version = os.environ["WATCHED_VERSION"] + stage = release_stage(version) + rollback = derive_latest_stable_rollback_tag(version, list_stable_tags()) + if not tag_exists(rollback): + raise ValueError("Watchdog rollback reference is unavailable") + print("metadata_mode=watchdog") + print(f"watched_version_stage={stage}") + print(f"rollback_tag={rollback}") + PY + else + # Candidate rehearsals retain every promotion and rollback gate. + python3 scripts/release_control/resolve_release_promotion.py \ + "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out" + echo "metadata_mode=promotion" >> "$RUNNER_TEMP/rehearsal-metadata.out" fi - python3 scripts/release_control/resolve_release_promotion.py \ - "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out" - { echo "version=${VERSION}" echo "tag=${TAG}" @@ -482,8 +499,10 @@ jobs: if-no-files-found: error - name: Write rehearsal summary + id: summary if: always() env: + WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }} TESTED_SHA: ${{ steps.source.outputs.tested_sha }} TESTED_BRANCH: ${{ steps.source.outputs.tested_branch }} NOTE: ${{ inputs.note }} @@ -506,8 +525,33 @@ jobs: run: | mkdir -p release-dry-run SUMMARY_FILE="release-dry-run/rc-to-ga-rehearsal-summary.md" + ARTIFACT_NAME="rc-to-ga-rehearsal-summary" + if [ "${WATCHDOG_MODE}" = "true" ]; then + SUMMARY_FILE="release-dry-run/watchdog-summary.md" + ARTIFACT_NAME="release-watchdog-summary" + fi + { + echo "summary_file=${SUMMARY_FILE}" + echo "artifact_name=${ARTIFACT_NAME}" + } >> "$GITHUB_OUTPUT" RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - if [ "${REHEARSAL_CONCLUSION}" != "success" ]; then + if [ "${WATCHDOG_MODE}" = "true" ]; then + { + echo "# Release Watchdog Observation" + echo "" + echo "- Workflow run: ${RUN_URL}" + echo "- Workflow event: ${GITHUB_REF_NAME}@${GITHUB_SHA}" + echo "- Tested source: ${TESTED_BRANCH:-unresolved}@${TESTED_SHA:-unresolved}" + echo "- Declared source version: ${WORKFLOW_OUTPUT_1:-unresolved}" + echo "- Preceding stable reference: ${WORKFLOW_OUTPUT_5:-unresolved}" + echo "- Source metadata observation: ${REHEARSAL_CONCLUSION}" + echo "- Preflight result: ${JOB_CONCLUSION}" + echo "" + echo "This observes branch drift, build and integration checks, not a candidate promotion." + echo "The definitive verdict also requires the stable demo's no-mutation verification." + echo "It does not establish promotion readiness, soak, qualification or installed recovery." + } > "$SUMMARY_FILE" + elif [ "${REHEARSAL_CONCLUSION}" != "success" ]; then { echo "# Prerelease-to-GA Rehearsal Summary" echo "" @@ -586,8 +630,9 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: rc-to-ga-rehearsal-summary - path: release-dry-run/rc-to-ga-rehearsal-summary.md + name: ${{ steps.summary.outputs.artifact_name }} + path: ${{ steps.summary.outputs.summary_file }} + if-no-files-found: error demo_path_preflight: name: Verify Current Stable Demo Path (No Mutation) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 3d2a3e82a7..82ca498eac 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -148,9 +148,24 @@ no-mutation verification and definitive verdict must all succeed. Delivery's reviewed dispatcher fixes repository, workflow, main and both inputs, retains intent before POST and never replays a refused or uncertain watchdog attempt. +A release line can still declare its published stable VERSION after reviewed +runtime fixes land. Watchdog metadata therefore observes the selected source's +supported version stage and preceding stable tag; it does not call the +candidate-promotion resolver, infer an RC, override soak or generate a promotion +envelope. Candidate rehearsals still use that unchanged resolver and all its +explicit rollback, ancestry, runtime-content and soak gates. Branch/VERSION +equality and missing-reference failures remain fatal in both paths. Watchdog +summaries retain actual preflight failure/success separately from the definitive +demo verdict, use the distinct `release-watchdog-summary` artifact, and cannot +be mistaken for the recorder's `rc-to-ga-rehearsal-summary` promotion evidence. + Verification: `rehearsal_source_test.py` executes the source-selection shell against local Git fixtures. `release_promotion_policy_test.py` pins workflow -ordering, metadata wiring and separate source reporting. Passing local fixtures +ordering, metadata wiring and separate source reporting. The executable fixtures +cover a post-publication runtime change with the same stable VERSION, the +identical candidate's continued promotion refusal, RC/missing-reference/version +failure paths and a failed watchdog's non-promotion summary. Registered +`build_release_assets_test.go` guards that evidence separation. Passing local fixtures does not establish hosted backend, integration or demo execution; those outcomes must be observed after landing. diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 277fd48d5d..76cca92212 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2444,6 +2444,37 @@ func TestReleaseWatchdogIsControlBoundAndCannotBuildCandidate(t *testing.T) { } } +func TestReleaseWatchdogDoesNotManufacturePromotionReadiness(t *testing.T) { + workflow, err := os.ReadFile(repoFile(".github", "workflows", "release-dry-run.yml")) + if err != nil { + t.Fatal(err) + } + text := string(workflow) + for _, required := range []string{ + `print("metadata_mode=watchdog")`, + `echo "metadata_mode=promotion"`, + `derive_latest_stable_rollback_tag(version, list_stable_tags())`, + `ARTIFACT_NAME="release-watchdog-summary"`, + `SUMMARY_FILE="release-dry-run/watchdog-summary.md"`, + `not a candidate promotion`, + `does not establish promotion readiness, soak, qualification or installed recovery`, + `name: ${{ steps.summary.outputs.artifact_name }}`, + `path: ${{ steps.summary.outputs.summary_file }}`, + } { + if !strings.Contains(text, required) { + t.Fatalf("watchdog/promotion evidence separation missing: %s", required) + } + } + if strings.Contains(text, "--derive-rollback-latest-stable") { + t.Fatal("watchdog must not manufacture a stable-promotion envelope from the branch VERSION") + } + assertFileContainsAll(t, repoFile("scripts", "release_control", "rehearsal_source_test.py"), + `test_postpublication_watchdog_does_not_pretend_to_promote_stable`, + `test_identical_candidate_rehearsal_still_refuses_new_stable_promotion`, + `test_watchdog_summary_cannot_be_recorded_as_promotion_readiness`, + ) +} + func TestDemoMutationAndRecoverySharePhysicalTargetLock(t *testing.T) { updateBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml")) if err != nil { diff --git a/scripts/release_control/rehearsal_source_test.py b/scripts/release_control/rehearsal_source_test.py index 3107917b19..b69b4e6dde 100644 --- a/scripts/release_control/rehearsal_source_test.py +++ b/scripts/release_control/rehearsal_source_test.py @@ -39,6 +39,7 @@ class RehearsalSourceTest(unittest.TestCase): self.git("config", "core.hooksPath", "/dev/null") for name in ("scripts/release_control/control_plane.py", "scripts/release_control/repo_file_io.py", + "scripts/release_control/resolve_release_promotion.py", "docs/release-control/control_plane.json"): target = self.repo / name target.parent.mkdir(parents=True, exist_ok=True) @@ -194,13 +195,99 @@ class RehearsalSourceTest(unittest.TestCase): metadata = step("Resolve rehearsal metadata") self.assertIn('if [ "${TESTED_BRANCH}" != "$REQUIRED_BRANCH" ]; then', metadata) self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata) - self.assertIn('if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', metadata) - self.assertIn('--derive-rollback-latest-stable', metadata) + self.assertIn('if [ "${WATCHDOG_MODE}" = "true" ]; then', metadata) + self.assertIn('metadata_mode=watchdog', metadata) + self.assertIn('metadata_mode=promotion', metadata) + self.assertNotIn('--derive-rollback-latest-stable', metadata) workflow = WORKFLOW.read_text() self.assertIn('TESTED_SHA: ${{ needs.dry-run.outputs.tested_sha }}', workflow) self.assertIn('Workflow event SHA:', workflow) self.assertNotIn('echo "- Source SHA:', workflow) + def metadata(self, watchdog="true", **overrides): + env = dict(self.env, EVENT_NAME="workflow_dispatch", WATCHDOG_MODE=watchdog, + TESTED_BRANCH="release/v6.4", WORKFLOW_OUTPUT_1="release/v6.4", + RUNNER_TEMP=str(self.root), GITHUB_OUTPUT=str(self.output)) + env.update(overrides) + return subprocess.run(["bash", "-euo", "pipefail", "-c", + step("Resolve rehearsal metadata")], + cwd=self.repo, env=env, text=True, capture_output=True) + + def published_stable_with_pending_fix(self): + self.git("tag", "v6.4.1", self.main) + self.git("checkout", "release/v6.4") + self.version("6.4.5") + self.git("tag", "v6.4.5-rc.5") + self.git("tag", "v6.4.5") + runtime = self.repo / "internal/updates/pending.go" + runtime.parent.mkdir(parents=True) + runtime.write_text("package updates\n") + self.git("add", ".") + self.git("commit", "-m", "reviewed pending fix after stable") + + def test_postpublication_watchdog_does_not_pretend_to_promote_stable(self): + self.published_stable_with_pending_fix() + before = self.git("rev-parse", "HEAD") + result = self.metadata() + self.assertEqual(result.returncode, 0, result.stderr) + observation = self.output.read_text() + self.assertIn("version=6.4.5\n", observation) + self.assertIn("metadata_mode=watchdog\n", observation) + self.assertIn("rollback_tag=v6.4.1\n", observation) + self.assertIn("watched_version_stage=stable\n", observation) + for candidate_field in ("promotion_mode=", "soak_hours=", "rollback_command=", + "hotfix_exception=", "promoted_from_tag="): + self.assertNotIn(candidate_field, observation) + self.assertEqual(self.git("rev-parse", "HEAD"), before) + self.assertEqual(self.git("status", "--porcelain"), "") + + def test_identical_candidate_rehearsal_still_refuses_new_stable_promotion(self): + self.published_stable_with_pending_fix() + result = self.metadata("false", ROLLBACK_VERSION_INPUT="v6.4.1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("same-version release candidates already exist", result.stderr) + self.assertFalse(self.output.exists()) + + def test_watchdog_rc_and_metadata_failures_remain_source_bound(self): + self.git("tag", "v6.4.1", self.main) + self.git("checkout", "release/v6.4") + self.version("6.4.6-rc.1") + self.assertEqual(self.metadata().returncode, 0) + self.assertIn("watched_version_stage=rc\n", self.output.read_text()) + self.output.unlink() + for overrides in ({"VERSION_INPUT": "6.4.5"}, + {"TESTED_BRANCH": "main"}): + with self.subTest(overrides=overrides): + self.assertNotEqual(self.metadata(**overrides).returncode, 0) + self.assertFalse(self.output.exists()) + self.git("tag", "-d", "v6.4.1") + missing = self.metadata() + self.assertNotEqual(missing.returncode, 0) + self.assertIn("no stable release tag precedes it", missing.stderr) + self.assertFalse(self.output.exists()) + + def test_watchdog_summary_cannot_be_recorded_as_promotion_readiness(self): + # Execute the real summary shell without an Actions expression evaluator. + script = step("Write rehearsal summary").replace( + "${{ github.repository }}", "rcourtman/Pulse").replace( + "${{ github.run_id }}", "123") + env = dict(self.env, WATCHDOG_MODE="true", GITHUB_OUTPUT=str(self.output), + GITHUB_STEP_SUMMARY=str(self.root / "step-summary"), + GITHUB_REF_NAME="main", GITHUB_SHA=self.main, + TESTED_BRANCH="release/v6.4", TESTED_SHA=self.release, + REHEARSAL_CONCLUSION="success", JOB_CONCLUSION="failure", + WORKFLOW_OUTPUT_1="6.4.5", WORKFLOW_OUTPUT_5="v6.4.1") + result = subprocess.run(["bash", "-euo", "pipefail", "-c", script], + cwd=self.repo, env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + text = (self.repo / "release-dry-run/watchdog-summary.md").read_text() + self.assertIn("- Preflight result: failure", text) + self.assertIn("not a candidate promotion", text) + self.assertNotIn("Candidate stable tag:", text) + self.assertNotIn("record_rc_to_ga_rehearsal.py", text) + self.assertIn("artifact_name=release-watchdog-summary\n", self.output.read_text()) + self.assertFalse((self.repo / "release-dry-run/rc-to-ga-rehearsal-summary.md").exists()) + if __name__ == "__main__": unittest.main() diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 066788c0ae..764f2ae41c 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -426,9 +426,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): metadata["run"]) self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata["run"]) self.assertIn( - 'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', + 'if [ "${WATCHDOG_MODE}" = "true" ]; then', metadata["run"], ) + self.assertIn('metadata_mode=watchdog', metadata["run"]) + self.assertIn('metadata_mode=promotion', metadata["run"]) self.assertEqual(metadata["env"]["WATCHDOG_MODE"], "${{ steps.mode.outputs.watchdog }}") self.assertEqual(jobs["build_release_candidate"]["if"], "${{ inputs.watchdog != true && inputs.version != '' }}") @@ -2209,13 +2211,15 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('workflow_dispatch:false)', dry_run_workflow) self.assertIn('[ -z "${ROLLBACK_VERSION_INPUT:-}" ]', dry_run_workflow) self.assertIn('Candidate rehearsal requires explicit rollback and no watchdog SHA.', dry_run_workflow) - # Both admitted watchdog modes derive rollback; a candidate rehearsal - # can never reach that derivation, including when the input is absent. + # Both admitted watchdog modes observe the preceding stable reference, + # without manufacturing a new promotion envelope for an already-shipped + # VERSION. Candidate rehearsals still use the complete resolver. self.assertIn( - 'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', + 'if [ "${WATCHDOG_MODE}" = "true" ]; then', dry_run_workflow, ) - self.assertIn("--derive-rollback-latest-stable", dry_run_workflow) + self.assertIn("derive_latest_stable_rollback_tag(version, list_stable_tags())", dry_run_workflow) + self.assertNotIn("--derive-rollback-latest-stable", dry_run_workflow) self.assertIn("--derive-rollback-latest-stable", resolver) self.assertIn("derive_latest_stable_rollback_tag", resolver) self.assertIn("Required: prior stable version to pin for rollback", content) From e2fb6b2faee62f6da75b62249da85aced3b71fe6 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:05:17 +0100 Subject: [PATCH 4/4] Match watchdog Git fixtures to the checkout ignore policy The first exact offline suite passed the metadata cases but the clean-tree assertion saw Python import bytecode because the fixture omitted the real .gitignore. Copy that existing policy into the fixture, retaining the source/no-mutation assertion. Contract-Neutral: Fixture setup now copies the existing repository ignore policy; runtime and deployment contracts are unchanged. Change-source: pulse-maintainer --- scripts/release_control/rehearsal_source_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/release_control/rehearsal_source_test.py b/scripts/release_control/rehearsal_source_test.py index b69b4e6dde..f297703760 100644 --- a/scripts/release_control/rehearsal_source_test.py +++ b/scripts/release_control/rehearsal_source_test.py @@ -37,7 +37,9 @@ class RehearsalSourceTest(unittest.TestCase): self.git("config", "user.email", "fixture@example.invalid") self.git("config", "user.name", "Fixture") self.git("config", "core.hooksPath", "/dev/null") - for name in ("scripts/release_control/control_plane.py", + # Match the real checkout's generated-file policy, including Python's + # import cache, while still detecting any uncommitted source change. + for name in (".gitignore", "scripts/release_control/control_plane.py", "scripts/release_control/repo_file_io.py", "scripts/release_control/resolve_release_promotion.py", "docs/release-control/control_plane.json"):