fix(web): retain PBS host target across transient snapshots (#1723)

The Backups drawer's Identity and History target is the merged PBS host resource. Correlation was recomputed from each snapshot with no memory, so a live refresh that briefly omitted the correlated host row made the drawer fall back to the PBS service target (agent/<service key>), which has no host history. The target flicked between the host series and the service key until the host row returned.

Retain the last resolved correlation per PBS server and reuse it only across such an omission, and only while the remembered host is still fresh relative to the server, so a genuinely removed or replaced host is not advertised indefinitely. A host row that is present but ambiguous still declines, as before.

Adds unit coverage for retention, staleness and pruning, a drawer regression test, and extends the real-browser guard and fixture to omit the host row and assert the identity rows and History chart survive.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-09-23 10:27:45 +01:00
parent 77b652adf7
commit 5d831b8765
10 changed files with 345 additions and 35 deletions

View file

@ -629,9 +629,13 @@ The Backups surface passes its complete deduplicated route inventory to the PBS
One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no host series and renders the collecting-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness.
A live snapshot can briefly omit the correlated host row while the PBS server row remains, for example while a realtime refresh replaces the merged estate. The correlation must retain the last resolved host per PBS server across that omission instead of falling back to the PBS service target, so the drawer's Discovery and Metrics Target rows and its History series do not flicker. Reuse the remembered host only while it is still fresh relative to the server, and drop it once stale so a removed or replaced host is not advertised indefinitely; a host row that is present but ambiguous still declines.
Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and
merged guest targets, missing disks, duplicate guest/host representations of one
agent, and genuinely ambiguous identities;
agent, genuinely ambiguous identities, and retaining the resolved host target
across a transient host-row omission;
ProxmoxBackupServersTable.test.ts covers retention, staleness and pruning;
ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication.

View file

@ -313,9 +313,13 @@ Backups hydrates type=pbs,agent with source=pbs and reuses Overview guest invent
The same agent can still arrive as two distinct resources: a PVE guest carrying its telemetry and the standalone `source=pbs` host row. Collapse those candidates by agent identity and resolve the Backups PBS row to the guest target, whose persisted host series the drawer charts; keeping the PBS service target leaves History on a key with no host data. Distinct agent identities stay ambiguous, and a missing agent identity is not sameness proof. Do not add a second guest-estate request to compensate.
A realtime refresh can briefly omit the correlated host row while the PBS server row remains. Retain the last resolved host per PBS server across that omission instead of falling back to the service target, so the drawer's Identity rows and History series stay on the host key. Reuse the remembered host only while it is still fresh relative to the server and drop it once stale, so a removed or replaced host is not advertised indefinitely; a present but ambiguous host still declines.
Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and
merged guest targets, missing disks, duplicate guest/host representations of one
agent, and genuinely ambiguous identities;
agent, genuinely ambiguous identities, and retaining the resolved host target
across a transient host-row omission;
ProxmoxBackupServersTable.test.ts covers retention, staleness and pruning;
ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication.

View file

@ -458,9 +458,13 @@ PBS presentation correlation accepts standalone agents and VM/system-container r
A single agent surfaced as both a VM/system-container guest and a standalone agent row is one identity, not an ambiguous pair. Collapse candidates that share an agent identity before deciding, and prefer the guest's canonical metricsTarget because it names the persisted host series; the PBS service target names the service key and has no host history. Only distinct agent identities, or candidates whose identity cannot be proven equal, decline to choose. Never use input order or a fallback that discards the PBS row id.
A realtime refresh can briefly omit the correlated host row while the PBS server row remains. Retain the last resolved correlation per PBS server across that omission rather than substituting the PBS service target, so the drawer's identity rows and history target stay stable. Reuse the remembered host only while it is still fresh relative to the server and drop it once stale, so a removed or replaced host is not advertised indefinitely; a present but ambiguous host still declines.
Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and
merged guest targets, missing disks, duplicate guest/host representations of one
agent, and genuinely ambiguous identities;
agent, genuinely ambiguous identities, and retaining the resolved host target
across a transient host-row omission;
ProxmoxBackupServersTable.test.ts covers retention, staleness and pruning;
ProxmoxPageSurface.contract.test.tsx covers hydration and deduplication.

View file

@ -110,11 +110,15 @@ const Fixture = () => {
// Simulates a snapshot in which the merged host correlation target is
// transiently absent, which is what gates the drawer's History tab.
const [dropMetricsTarget, setDropMetricsTarget] = createSignal(false);
// Simulates a live refresh that briefly omits the correlated host row while
// the PBS server row remains, which is what flipped the drawer's Identity and
// History target between the host series and the service key (#1723).
const [dropHostRows, setDropHostRows] = createSignal(false);
let timer: ReturnType<typeof setTimeout> | undefined;
onCleanup(() => clearTimeout(timer));
const refresh = () => {
const count = snapshot() + 1;
const next = structuredClone(resources);
let next = structuredClone(resources);
next[1].cpu = { current: 15.4 + count };
const tank = next[0].pbs!.datastores!.find((store) => store.name === 'tank')!;
tank.used = 400 + count * 10;
@ -126,6 +130,9 @@ const Fixture = () => {
if (dropMetricsTarget()) {
for (const resource of next) delete resource.metricsTarget;
}
if (dropHostRows()) {
next = next.filter((resource) => resource.type === 'pbs');
}
setServers(next);
setSnapshot(count);
};
@ -156,6 +163,22 @@ const Fixture = () => {
>
Restore metrics target
</button>
<button
onClick={() => {
setDropHostRows(true);
refresh();
}}
>
Drop correlated host
</button>
<button
onClick={() => {
setDropHostRows(false);
refresh();
}}
>
Restore correlated host
</button>
<output aria-label="Snapshot number">{snapshot()}</output>
<ProxmoxBackupServersTable servers={servers()} />
</>

View file

@ -1,16 +1,21 @@
{
"version": 1,
"base_sha": "79cf2d0058c374adc7fcc2e7268ab2261e10fa24",
"verified_at": "2026-09-22T23:54:27Z",
"base_sha": "77b652adf78817516f236079aef9c9252233b3ce",
"verified_at": "2026-09-23T09:22:16Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts"
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "24f77d42e2542dd0e8fcf9c55f25a8373c563069c5a0dc112afa6a4456d5a93a"
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b19198cb055e5f0f107c2d6749d6def719bd84310de585e3b6a349fa22fac3c1"
},
"routes": [
"/browser-tests/pbs-host-history-correlation.html?topology=guest&order=stable (Proxmox Backups server drawer)"
"/browser-tests/pbs-host-history-correlation.html?topology=guest&order=stable (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=guest&order=reordered (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=pbs-only&order=stable (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=pbs-only&order=reordered (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=side-by-side&order=stable (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=side-by-side&order=reordered (Proxmox Backups server drawer)"
],
"viewports": [
{
@ -23,16 +28,17 @@
}
],
"states": [
"Drawer History tab selected with a populated host history target",
"Snapshot with the merged host metrics target transiently absent (availability notice)",
"Snapshot with the metrics target restored on the same selection"
"Drawer History tab selected with a populated correlated host series",
"Timer-driven snapshot replacing the datastore inventory in stable and reversed order",
"Snapshot that briefly omits the correlated host row while the PBS server row remains",
"Snapshot that restores the correlated host row on the same selection"
],
"interactions": [
"Expand the Proxmox Backups server row and select the History tab; assert the chart paths render",
"Apply a snapshot that removes the merged host metrics target; assert the drawer is not remounted and the History selection is retained, showing the in-tab availability notice",
"Apply a snapshot that restores the metrics target; assert the same History selection recovers the chart paths",
"Repeat at 1280x900 and 390x844; the same check fails on the pre-fix source (no notice, selection reset)"
"Expand the Proxmox Backups server row, switch between datastore rows and select History; assert the chart paths render against the host metrics target",
"Apply timer-driven stable and reordered snapshots; assert the drawer is not remounted, the History selection is retained, the chart series survive and the Discovery/Metrics Target rows stay byte-identical",
"Apply a snapshot that omits the correlated host row; assert the Identity rows and History chart stay on the retained host target instead of falling back to the PBS service key",
"Restore the correlated host row; assert the same target is confirmed, and repeat the complete matrix at 1280x900 and 390x844"
],
"command": "pulse-worker-browser scripts/check-drawer-tab-retention.cjs",
"notes": "Chromium 141.0.7390.37 / Playwright 1.56.1. Production ResourceDetailDrawer mounted through the real ProxmoxBackupServersTable fixture with synthetic API responses. Proves the drawer tab-retention behaviour, not backend persistence or installed release behaviour. The identical check timed out on the pre-fix source because the selected tab reset to Overview."
"command": "pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs",
"notes": "Chromium 141.0.7390.37 / Playwright 1.56.1. Production ProxmoxBackupServersTable mounted through the real browser fixture with synthetic API responses and an intercepted metrics-history request. Proves the retained host correlation, not backend persistence or installed release behaviour. The same check fails on the pre-fix source: omitting the host row flips the drawer's Discovery and Metrics Target rows to agent:pbs-1 and drops the host series."
}

View file

@ -144,21 +144,23 @@ const correlatedAgentKey = (resource: Resource): string | undefined => {
return undefined;
};
// Host telemetry can be merged into a PVE guest rather than a standalone
// agent. Keep the unique-identity check and require an actual agent facet.
const isCorrelationCandidate = (serverTokens: Set<string>, candidate: Resource): boolean => {
if (candidate.type !== 'agent' && !isGuestWithAgent(candidate)) return false;
for (const token of identityTokens(candidate)) {
if (serverTokens.has(token)) return true;
}
return false;
};
const uniquelyCorrelatedAgent = (
server: Resource,
candidates: readonly Resource[],
): Resource | undefined => {
const serverTokens = identityTokens(server);
if (serverTokens.size === 0) return undefined;
const matches = candidates.filter((candidate) => {
// Host telemetry can be merged into a PVE guest rather than a standalone
// agent. Keep the unique-identity check and require an actual agent facet.
if (candidate.type !== 'agent' && !isGuestWithAgent(candidate)) return false;
for (const token of identityTokens(candidate)) {
if (serverTokens.has(token)) return true;
}
return false;
});
const matches = candidates.filter((candidate) => isCorrelationCandidate(serverTokens, candidate));
if (matches.length === 0) return undefined;
if (matches.length === 1) return matches[0];
@ -188,6 +190,30 @@ const uniquelyCorrelatedAgent = (
);
};
// True when the snapshot still offers a host row for this server, even if the
// match is ambiguous and `uniquelyCorrelatedAgent` declines to choose. The
// distinction matters for correlation retention: a snapshot that simply omits
// the host row is a transient refresh gap, while an ambiguous snapshot is a
// deliberate decline that must not be papered over with a remembered guess.
const hasCorrelationCandidate = (server: Resource, candidates: readonly Resource[]): boolean => {
const serverTokens = identityTokens(server);
if (serverTokens.size === 0) return false;
return candidates.some((candidate) => isCorrelationCandidate(serverTokens, candidate));
};
// A live refresh can briefly omit the correlated host row (for example while a
// realtime snapshot replaces the merged estate), which used to flip the Backups
// drawer's Identity and History target between the host series and the PBS
// service key. Keep the last resolved correlation per PBS server and reuse it
// only across such an omission, and only while the remembered host is still
// plausibly current, so a genuinely removed or replaced host is not advertised
// indefinitely. A host that is present but ambiguous still declines.
export type PbsCorrelationRetention = Map<string, Resource>;
export const createPbsCorrelationRetention = (): PbsCorrelationRetention => new Map();
const PBS_CORRELATION_RETENTION_MAX_STALENESS_MS = 5 * 60 * 1000;
const mergePBSAgentPresentation = (server: Resource, agent: Resource): Resource => {
const serverPlatform = server.platformData ?? {};
const agentPlatform = agent.platformData ?? {};
@ -225,6 +251,7 @@ const mergePBSAgentPresentation = (server: Resource, agent: Resource): Resource
export function buildBackupServerRows(
servers: readonly Resource[],
backups: readonly PBSBackup[] = [],
retention?: PbsCorrelationRetention,
): BackupServerRow[] {
const rows: BackupServerRow[] = [];
const counts = buildBackupCounts(backups);
@ -241,14 +268,33 @@ export function buildBackupServerRows(
// *datastore* storage resources (type 'storage', sources ['pbs']). This table
// is about the server, so keep only actual PBS server instances — otherwise a
// datastore renders as a phantom offline "server" row.
const sortedServers = servers
.filter((resource) => resource.type === 'pbs')
const pbsServers = servers.filter((resource) => resource.type === 'pbs');
const sortedServers = pbsServers
.map((server) => {
const agent = uniquelyCorrelatedAgent(server, servers);
return agent ? mergePBSAgentPresentation(server, agent) : server;
if (agent) {
retention?.set(server.id, agent);
return mergePBSAgentPresentation(server, agent);
}
const retained = retention?.get(server.id);
if (retained) {
const fresh =
server.lastSeen - retained.lastSeen <= PBS_CORRELATION_RETENTION_MAX_STALENESS_MS;
if (fresh && !hasCorrelationCandidate(server, servers)) {
return mergePBSAgentPresentation(server, retained);
}
if (!fresh) retention?.delete(server.id);
}
return server;
})
.slice()
.sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
if (retention) {
const presentIds = new Set(pbsServers.map((server) => server.id));
for (const id of retention.keys()) {
if (!presentIds.has(id)) retention.delete(id);
}
}
for (const server of sortedServers) {
const datastores = (server.pbs?.datastores ?? [])
.slice()
@ -296,7 +342,10 @@ export function ProxmoxBackupServersTable(props: {
emptyIcon?: JSX.Element;
layoutWidth?: Accessor<number | null | undefined>;
}) {
const rows = () => buildBackupServerRows(props.servers, props.backups ?? []);
const retention = createPbsCorrelationRetention();
const rows = createMemo(() =>
buildBackupServerRows(props.servers, props.backups ?? [], retention),
);
const observedWidth = useObservedElementWidth();
const layoutMode = createMemo(() => {
const width = props.layoutWidth?.() ?? observedWidth.width();

View file

@ -280,4 +280,76 @@ describe('ProxmoxBackupServersTable details', () => {
'proxmox:100',
);
});
it('retains the correlated host target when a refreshed snapshot omits the agent row', async () => {
const makeServers = (): [Resource, Resource] => {
const pbs = makePbsResource();
pbs.id = 'pbs-1';
pbs.name = 'proxback';
pbs.displayName = 'proxback';
pbs.platformId = 'pbs-1';
pbs.sources = ['pbs'];
pbs.agent = undefined;
// The PBS service target names the service key, not the host series.
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'proxback' };
pbs.pbs = {
instanceId: 'proxback',
hostname: 'proxback-vm',
version: '3.2.1',
connectionHealth: 'healthy',
datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }],
};
pbs.platformData = {
sources: ['pbs'],
pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 1 },
};
const agent = {
id: 'agent-proxback',
type: 'agent',
name: 'proxback-vm',
displayName: 'proxback-vm',
platformId: 'agent-proxback',
platformType: 'proxmox-pbs',
sourceType: 'hybrid',
sources: ['agent', 'pbs'],
status: 'online',
lastSeen: pbs.lastSeen + 1000,
agent: { agentId: 'agent-proxback', hostname: 'proxback-vm' },
metricsTarget: { resourceType: 'agent', resourceId: 'agent-proxback' },
platformData: {
sources: ['agent', 'pbs'],
agent: { agentId: 'agent-proxback', hostname: 'proxback-vm' },
},
} as Resource;
return [pbs, agent];
};
const [pbs, agent] = makeServers();
const [servers, setServers] = createSignal<Resource[]>([pbs, agent]);
render(() => <ProxmoxBackupServersTable servers={servers()} />);
fireEvent.click(screen.getByRole('button', { name: 'Expand details for proxback' }));
expect(screen.getByTestId('pbs-resource-detail')).toHaveAttribute(
'data-metrics-resource-id',
'agent-proxback',
);
// A live refresh snapshot can transiently omit the correlated agent row.
// The drawer target must not fall back to the PBS service target.
setServers([pbs]);
await waitFor(() =>
expect(screen.getByTestId('pbs-resource-detail')).toHaveAttribute(
'data-metrics-resource-id',
'agent-proxback',
),
);
// When the agent returns, the retained correlation is confirmed, not re-guessed.
setServers([pbs, agent]);
await waitFor(() =>
expect(screen.getByTestId('pbs-resource-detail')).toHaveAttribute(
'data-metrics-resource-id',
'agent-proxback',
),
);
});
});

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { buildBackupServerRows } from '../ProxmoxBackupServersTable';
import { buildBackupServerRows, createPbsCorrelationRetention } from '../ProxmoxBackupServersTable';
const makePbsResource = (overrides: Partial<Resource> = {}): Resource =>
({
@ -109,3 +109,96 @@ describe('buildBackupServerRows', () => {
expect(rows[0].uptimeSeconds).toBeUndefined();
});
});
const makeCorrelatablePbs = (lastSeen = 1_700_000_000_000): Resource =>
makePbsResource({
id: 'pbs-1',
name: 'proxback',
displayName: 'proxback',
platformId: 'proxback',
sources: ['pbs'],
lastSeen,
pbs: {
instanceId: 'proxback',
hostname: 'proxback-vm',
connectionHealth: 'healthy',
datastores: [{ name: 'tank', total: 1_000, used: 400, available: 600 }],
},
// The PBS service target names the service key, not the host series.
metricsTarget: { resourceType: 'agent', resourceId: 'proxback' },
});
const makeCorrelatedAgent = (overrides: Partial<Resource> = {}): Resource =>
({
id: 'agent-proxback',
type: 'agent',
name: 'proxback',
displayName: 'proxback',
platformId: 'agent-proxback',
platformType: 'proxmox-pbs',
sourceType: 'hybrid',
sources: ['agent', 'pbs'],
status: 'online',
lastSeen: 1_700_000_000_000,
agent: { agentId: 'agent-proxback', hostname: 'proxback' },
metricsTarget: { resourceType: 'agent', resourceId: 'agent-proxback' },
...overrides,
}) as Resource;
describe('buildBackupServerRows PBS host correlation retention', () => {
it('retains the resolved host target while a refresh snapshot omits the host row', () => {
const retention = createPbsCorrelationRetention();
const pbs = makeCorrelatablePbs();
const agent = makeCorrelatedAgent();
const first = buildBackupServerRows([pbs, agent], [], retention);
expect(first[0].resource.metricsTarget?.resourceId).toBe('agent-proxback');
// The host row is briefly absent; the service target must not replace it.
const omitted = buildBackupServerRows([pbs], [], retention);
expect(omitted[0].resource.metricsTarget?.resourceId).toBe('agent-proxback');
// A later snapshot that carries the host again confirms the same target.
const restored = buildBackupServerRows([pbs, agent], [], retention);
expect(restored[0].resource.metricsTarget?.resourceId).toBe('agent-proxback');
});
it('does not reuse a remembered host when the current snapshot is ambiguous', () => {
const retention = createPbsCorrelationRetention();
const pbs = makeCorrelatablePbs();
const agent = makeCorrelatedAgent();
buildBackupServerRows([pbs, agent], [], retention);
const otherAgent = makeCorrelatedAgent({
id: 'agent-other',
platformId: 'agent-other',
agent: { agentId: 'agent-other', hostname: 'proxback' },
metricsTarget: { resourceType: 'agent', resourceId: 'agent-other' },
});
const ambiguous = buildBackupServerRows([pbs, agent, otherAgent], [], retention);
expect(ambiguous[0].resource.metricsTarget?.resourceId).toBe('proxback');
});
it('drops a remembered host once it is stale relative to the server', () => {
const retention = createPbsCorrelationRetention();
const agent = makeCorrelatedAgent({ lastSeen: 1_700_000_000_000 });
const pbs = makeCorrelatablePbs(1_700_000_000_000);
buildBackupServerRows([pbs, agent], [], retention);
const stale = makeCorrelatablePbs(1_700_000_000_000 + 6 * 60 * 1000);
const rows = buildBackupServerRows([stale], [], retention);
expect(rows[0].resource.metricsTarget?.resourceId).toBe('proxback');
expect(retention.size).toBe(0);
});
it('prunes remembered hosts for servers that are no longer present', () => {
const retention = createPbsCorrelationRetention();
const pbs = makeCorrelatablePbs();
buildBackupServerRows([pbs, makeCorrelatedAgent()], [], retention);
expect(retention.has('pbs-1')).toBe(true);
buildBackupServerRows([makePbsResource({ id: 'pbs-2', name: 'other' })], [], retention);
expect(retention.has('pbs-1')).toBe(false);
});
});

View file

@ -595,6 +595,14 @@ describe('ProxmoxBackupsTable', () => {
expect(proxmoxBackupServersTableSource).toContain(
'metricsTarget: agent.metricsTarget ?? server.metricsTarget',
);
// A refresh that briefly omits the correlated host row must not flip the
// drawer target to the PBS service key; retain the resolved host per server
// and reuse it only across the omission.
expect(proxmoxBackupServersTableSource).toContain('createPbsCorrelationRetention');
expect(proxmoxBackupServersTableSource).toContain('hasCorrelationCandidate');
expect(proxmoxBackupServersTableSource).toContain(
'buildBackupServerRows(props.servers, props.backups ?? [], retention)',
);
expect(proxmoxBackupServersTableSource).not.toContain(
'<span class="font-medium text-base-content">Server:</span>',
);

View file

@ -163,6 +163,11 @@ const launchOptions = {
};
// Both datastore rows must use the same host series, including revisiting
// the first row after its history component has been disposed.
let retainedIdentity = [];
let retainedPaths = 0;
const detail = page.locator(
'[data-inline-platform-resource-detail-for="pbs-1"]',
);
for (const [index, datastore] of [
'tank',
'archive',
@ -177,9 +182,6 @@ const launchOptions = {
['proxback · archive', 'proxback · tank'],
);
await openDatastore(datastore);
const detail = page.locator(
'[data-inline-platform-resource-detail-for="pbs-1"]',
);
await detail.evaluate((node) => {
node.dataset.proofIdentity = 'retained';
});
@ -238,6 +240,8 @@ const launchOptions = {
identityBefore,
'Identity rows changed after a refreshed snapshot',
);
retainedIdentity = identityAfter;
retainedPaths = pathsBefore;
observations.push({
datastore,
paths: pathsBefore,
@ -246,6 +250,49 @@ const launchOptions = {
});
}
// A live refresh can briefly omit the correlated host row while the
// PBS server row stays. That must not flip the drawer's Identity or
// History target to the PBS service key (#1723).
await page.getByRole('button', { name: 'Drop correlated host' }).click();
await page
.getByRole('status', { name: 'Snapshot number' })
.filter({ hasText: '4' })
.waitFor();
assert.equal(
await detail.getAttribute('data-proof-identity'),
'retained',
'drawer remounted after the host row was omitted',
);
assert.equal(
await page
.getByRole('tab', { name: 'History', exact: true })
.getAttribute('aria-selected'),
'true',
'history reset after the host row was omitted',
);
assert.equal(
await detail
.locator('[data-testid="guest-history-plot"] path')
.count(),
retainedPaths,
'history chart lost its series when the host row was omitted',
);
assert.deepEqual(
await readIdentityRows(detail),
retainedIdentity,
'Identity rows changed when the host row was transiently omitted',
);
observations.push({
hostRowOmitted: true,
identity: retainedIdentity,
refreshPreservedHistory: true,
});
await page.getByRole('button', { name: 'Restore correlated host' }).click();
await page
.getByRole('status', { name: 'Snapshot number' })
.filter({ hasText: '5' })
.waitFor();
if (!targets.includes(expected)) {
throw new Error(
`${viewport.width}px: expected the host history target ${expected}; recorded ${JSON.stringify(targets)}`,