mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Make TrueNAS troubleshooting safer and History timelines accurate (#2359)
## What changed and why - **Safer TrueNAS troubleshooting.** Existing help put Pulse tokens and TrueNAS keys in shell arguments and requested unbounded logs. It now uses private credential files, tests before saving, bounds logs, and distinguishes hardware capacity from actual CPU/memory usage and History. This also addresses the misleading capacity-versus-usage evidence seen in #2077 without claiming to fix that appliance's missing metrics. - **Accurate drawer History timelines.** Sparse observations were stretched independently across each panel, making five minutes look like a full day. Panels now share the fulfilled, dated time window, preserve real edge observations, retain matching bounds on failed refresh, and clear them when the target or range changes. Existing keyboard/touch inspection and polling limits remain. - **Honest release-watchdog observations.** After stable publication, a release branch can retain the stable VERSION while repairs accumulate. The watchdog now observes that exact source and its preceding stable reference instead of treating it as a new promotion. Candidate promotion gates remain unchanged; watchdog artifacts cannot serve as promotion-readiness evidence. ## Included candidates and complete range Pulse main: `e085c2e583fde3af58699ccc9c87f28bcb24715b..920aa2f27c6cb717cca7eb8e2cd3f1379d0fdf73` (four source commits and four additive integration/reconciliation commits). - TrueNAS guidance: `19fa6b1c8dffacd10c0a9dbf025b039844eebbce`, candidate 20261001T010405Z-product-intelligence. - Dated History window: `5e886b04d67fbf9edf555d7379db932d28117c3b`, candidate 20261001T010405Z-web-product. - Watchdog observation and fixture correction: `4101c37db548ca85ca8500f433b15a3c45fba2ab..e2fb6b2faee62f6da75b62249da85aced3b71fe6`, including both commits, candidate 20261001T014538Z-delivery-trust. The reviewed identities and ancestry are preserved. Reconciliation `ffb6443862e038d5a050e8a941913f9617f46bd8` already incorporates current published main; no additional upstream merge or source correction was needed at this gate. ## Validation and limits Existing sufficient exact-source evidence is reused: - TrueNAS: `source-kqbglj0r` passes 23 Python/synthetic-recipe checks; `source-n7uk8dco` passes 22 docs-link tests. Three Docs-renderer browser cases pass across Chromium/Firefox desktop and WebKit phone emulation; six screenshots were recorded as inspected. - History: regression-first `source-fbz6bjig` records ten failures. `source-4inm_uzq` passes type-check/lint but retains an AIChat import-hook timeout; `source-x5tvsgds` passes the complete serial suite (21,604 tests, three existing skips), embed build, bundle budgets, formatting, headers and duplication checks without loosening assertions. Eight production-renderer browser cases pass, with 40 screenshots recorded as inspected. WebKit whole-row touch opening and screen-reader speech remain unproved. - Watchdog: final `source-z97o6_t2` passes 121 Python tests and five Go regressions. The earlier fixture failure remains recorded; its additive correction copies the existing checkout ignore policy without weakening the clean-source assertion. - Combined tip: provenance and whitespace pass; per-source-commit and aggregate canonical completion checks pass. The range-aware browser guard confirms both final changed frontend source blobs are covered by parent-bound in-range receipts. Final blobs match the reviewed candidates; intervening upstream changes affect alerts, not these candidates' source or dependency graphs. A confined native-Git fixture check passes 74 rehearsal/promotion tests. Its initial incorrect Python import-path invocation failed before running the suites and is retained separately. These are synthetic/offline source and UI checks, not actual TrueNAS/PBS acceptance, restored hosted watchdog coverage, installed update/rollback proof or release qualification. Exact-head required CI and independent review still govern landing. This PR changes no release version or tags and makes no release-readiness claim. The separate v6.5 proposal #2344 remains unchanged and dependency-audit-blocked under its existing repair ownership; it is not part of this main batch. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
@@ -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)
|
||||
|
||||
+67
-18
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
normalizeGuestDrawerHistoryPoints,
|
||||
type GuestDrawerHistoryGroupConfig,
|
||||
type GuestDrawerHistoryTarget,
|
||||
type GuestDrawerHistoryTimeBounds,
|
||||
} from './guestDrawerModel';
|
||||
|
||||
interface GuestDrawerHistoryProps {
|
||||
@@ -68,6 +69,7 @@ interface GuestDrawerHistoryGroupChartProps {
|
||||
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
|
||||
range: HistoryTimeRange;
|
||||
sourceKey: string;
|
||||
timeBounds: GuestDrawerHistoryTimeBounds | null;
|
||||
}
|
||||
|
||||
const GUEST_DRAWER_HISTORY_MAX_POINTS = 240;
|
||||
@@ -273,7 +275,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
});
|
||||
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<GuestDrawerHistoryGroupChartProps>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={bounds()}>
|
||||
{(window) => (
|
||||
<div
|
||||
class="ml-[34px] mr-2 mt-1 flex justify-between gap-2 text-[10px] tabular-nums text-muted"
|
||||
data-testid="guest-history-time-window"
|
||||
>
|
||||
<For
|
||||
each={[
|
||||
{ label: 'Window start', timestamp: window().startTime },
|
||||
{ label: 'Window end', timestamp: window().endTime },
|
||||
]}
|
||||
>
|
||||
{(endpoint) => (
|
||||
<time
|
||||
dateTime={new Date(endpoint.timestamp).toISOString()}
|
||||
aria-label={`${endpoint.label}: ${new Date(endpoint.timestamp).toLocaleString()}`}
|
||||
title={new Date(endpoint.timestamp).toLocaleString()}
|
||||
>
|
||||
{new Date(endpoint.timestamp).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<p id={`${inspectionId}-description`} class="sr-only">
|
||||
{chartDescription()}
|
||||
</p>
|
||||
@@ -560,6 +592,16 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (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<GuestDrawerHistoryProps> = (props) =>
|
||||
currentMetrics={props.currentMetrics}
|
||||
range={props.range}
|
||||
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
|
||||
timeBounds={timeBounds()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
+243
@@ -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> = {},
|
||||
): 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(() => (
|
||||
<GuestDrawerHistory target={target} range="24h" groups={HOST_METRICS_HISTORY_GROUPS} />
|
||||
));
|
||||
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<HistoryTimeRange>('24h');
|
||||
render(() => (
|
||||
<GuestDrawerHistory target={target} range={range()} groups={HOST_METRICS_HISTORY_GROUPS} />
|
||||
));
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -37,8 +37,11 @@ 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"):
|
||||
target = self.repo / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -194,13 +197,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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user