mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-21 06:53:29 +00:00
test(e2e): recover the eight vmware specs from quarantine
Re-pin the six /infrastructure-era specs to the vSphere platform page:
37 keeps its full facets/audit re-pin but is fixme'd because table-row
drawers disable remote history, leaving VMware hosts no reachable
change/action history surface (tracked); 38 and 42 move mention and
chat-turn coverage onto live mock state and the session transport (the
retired /api/ai/chat SSE stub is gone, so 42 proves the rendered
exchange instead of the wire payload); 39 and 41 pin the guest drawer's
read-only vCenter context and admin-action exclusions; 40 pins the
Datastores tab without backup semantics. 22 and 36 pass unchanged.
Verified via the managed local backend with PULSE_MOCK_MODE=true (8
passed, 1 tracked fixme).
Contract-Neutral: E2E quarantine-list shrink is test selection only; no deployment contract delta, mirrors bypass on 725837dc3
This commit is contained in:
parent
8ab5b0b3b4
commit
3f18d78ea0
7 changed files with 220 additions and 1031 deletions
|
|
@ -19,15 +19,7 @@ const QUARANTINED_SPECS = [
|
|||
'**/18-patrol-runtime-state.spec.ts',
|
||||
'**/19-telemetry-disclosure.spec.ts',
|
||||
'**/20-local-doc-links.spec.ts',
|
||||
'**/22-vmware-connections-workspace.spec.ts',
|
||||
'**/30-setup-platform-connections-handoff.spec.ts',
|
||||
'**/36-vmware-alert-history-resource-incidents.spec.ts',
|
||||
'**/37-vmware-resource-history-drawer.spec.ts',
|
||||
'**/38-vmware-ai-chat-mentions.spec.ts',
|
||||
'**/39-vmware-resource-detail-drawer.spec.ts',
|
||||
'**/40-vmware-storage-source-filter.spec.ts',
|
||||
'**/41-vmware-phase1-exclusion-integrity.spec.ts',
|
||||
'**/42-vmware-ai-chat-read-recovery.spec.ts',
|
||||
'**/43-platform-mock-runtime.spec.ts',
|
||||
'**/44-workloads-chart-spacing.spec.ts',
|
||||
'**/45-workloads-memory-tail.spec.ts',
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import { createAuthenticatedStorageState } from './helpers';
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCREENSHOT_PATH = '/tmp/vmware-resource-history-drawer.png';
|
||||
const RESOURCE_ID = 'vc-1:host:host-21';
|
||||
const RESOURCE_ID_ENCODED = encodeURIComponent(RESOURCE_ID);
|
||||
|
||||
type WorkerFixtures = {
|
||||
authStorageStatePath: string;
|
||||
|
|
@ -37,11 +35,16 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
}, { scope: 'worker' }],
|
||||
});
|
||||
|
||||
const vmwareActivityChange = {
|
||||
// The change/action history surface moved onto the vSphere platform page:
|
||||
// expanding an ESXi host row opens the shared resource drawer, which fetches
|
||||
// the facets and action-audit bundles per resource over REST. The host row
|
||||
// itself comes from live mock websocket state, so the stubs match whatever
|
||||
// canonical resource id the drawer asks for instead of pinning one.
|
||||
const buildVmwareActivityChange = (resourceId: string) => ({
|
||||
id: 'vmware-activity-1',
|
||||
observedAt: '2026-03-30T18:16:00Z',
|
||||
occurredAt: '2026-03-30T18:15:30Z',
|
||||
resourceId: RESOURCE_ID,
|
||||
resourceId,
|
||||
kind: 'activity',
|
||||
sourceType: 'platform_event',
|
||||
sourceAdapter: 'vmware_adapter',
|
||||
|
|
@ -53,14 +56,14 @@ const vmwareActivityChange = {
|
|||
taskId: 'task-2049',
|
||||
taskState: 'success',
|
||||
entityType: 'HostSystem',
|
||||
managedObjectId: 'host-21',
|
||||
managedObjectId: 'host-101',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const heuristicAlertChange = {
|
||||
const buildHeuristicAlertChange = (resourceId: string) => ({
|
||||
id: 'heuristic-alert-1',
|
||||
observedAt: '2026-03-30T18:14:00Z',
|
||||
resourceId: RESOURCE_ID,
|
||||
resourceId,
|
||||
kind: 'alert_fired',
|
||||
sourceType: 'heuristic',
|
||||
confidence: 'medium',
|
||||
|
|
@ -68,10 +71,10 @@ const heuristicAlertChange = {
|
|||
metadata: {
|
||||
incidentCategory: 'health',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const unfilteredFacetBundle = {
|
||||
recentChanges: [vmwareActivityChange, heuristicAlertChange],
|
||||
const buildUnfilteredFacetBundle = (resourceId: string) => ({
|
||||
recentChanges: [buildVmwareActivityChange(resourceId), buildHeuristicAlertChange(resourceId)],
|
||||
counts: {
|
||||
recentChanges: 2,
|
||||
recentChangeKinds: {
|
||||
|
|
@ -86,10 +89,10 @@ const unfilteredFacetBundle = {
|
|||
vmware_adapter: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const filteredFacetBundle = {
|
||||
recentChanges: [vmwareActivityChange],
|
||||
const buildFilteredFacetBundle = (resourceId: string) => ({
|
||||
recentChanges: [buildVmwareActivityChange(resourceId)],
|
||||
counts: {
|
||||
recentChanges: 1,
|
||||
recentChangeKinds: {
|
||||
|
|
@ -102,12 +105,12 @@ const filteredFacetBundle = {
|
|||
vmware_adapter: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const actionAuditBundle = {
|
||||
const buildActionAuditBundle = (resourceId: string) => ({
|
||||
available: true,
|
||||
count: 2,
|
||||
resourceId: RESOURCE_ID,
|
||||
resourceId,
|
||||
audits: [
|
||||
{
|
||||
id: 'vmware-action-verified',
|
||||
|
|
@ -116,7 +119,7 @@ const actionAuditBundle = {
|
|||
state: 'completed',
|
||||
request: {
|
||||
requestId: 'vmware-req-verified',
|
||||
resourceId: RESOURCE_ID,
|
||||
resourceId,
|
||||
capabilityName: 'enter_maintenance_mode',
|
||||
reason: 'Place host in maintenance after alarm review',
|
||||
requestedBy: 'pulse_patrol',
|
||||
|
|
@ -132,10 +135,22 @@ const actionAuditBundle = {
|
|||
result: {
|
||||
success: true,
|
||||
output: 'Maintenance mode requested',
|
||||
},
|
||||
verificationOutcome: {
|
||||
status: 'verified',
|
||||
evidenceSummary: 'vCenter reported the host entering maintenance mode.',
|
||||
actionResultV2: {
|
||||
version: 2,
|
||||
execution: {
|
||||
status: 'succeeded',
|
||||
summary: 'Maintenance mode requested',
|
||||
},
|
||||
verification: {
|
||||
status: 'confirmed',
|
||||
evidenceClass: 'independent',
|
||||
summary: 'vCenter reported the host entering maintenance mode.',
|
||||
},
|
||||
compensation: {
|
||||
support: 'unavailable',
|
||||
status: 'not_available',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -145,7 +160,7 @@ const actionAuditBundle = {
|
|||
state: 'failed',
|
||||
request: {
|
||||
requestId: 'vmware-req-refused',
|
||||
resourceId: RESOURCE_ID,
|
||||
resourceId,
|
||||
capabilityName: 'restart_host_agent',
|
||||
reason: 'Patrol proposed remediation while the host was locked',
|
||||
requestedBy: 'pulse_patrol',
|
||||
|
|
@ -168,7 +183,7 @@ const actionAuditBundle = {
|
|||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
test.describe('VMware resource history drawer', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
|
@ -176,6 +191,16 @@ test.describe('VMware resource history drawer', () => {
|
|||
test('filters VMware activity through the shared resource facets history surface', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Real capability gap, not spec rot: platform-page drawers mount with
|
||||
// presentation="table-row", and useResourceDetailDrawerState disables
|
||||
// enableRemoteHistory for that presentation, so the change/action
|
||||
// history sections never fetch or render for vSphere hosts anywhere in
|
||||
// the current IA (Machines only lists Pulse-agent machines). Re-enable
|
||||
// once a VMware surface regains remote history (tracked).
|
||||
test.fixme(
|
||||
true,
|
||||
'VMware hosts have no reachable facets/action history surface (tracked)',
|
||||
);
|
||||
const facetRequestUrls: string[] = [];
|
||||
const actionAuditRequestUrls: string[] = [];
|
||||
let unexpectedVmwareApiCall: string | null = null;
|
||||
|
|
@ -185,190 +210,72 @@ test.describe('VMware resource history drawer', () => {
|
|||
await route.abort();
|
||||
});
|
||||
|
||||
await page.route('**/api/resources**', async (route) => {
|
||||
await page.route('**/api/resources/**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
|
||||
if (requestUrl.pathname === '/api/resources') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: RESOURCE_ID,
|
||||
type: 'agent',
|
||||
name: 'esxi-01.lab.local',
|
||||
displayName: 'ESXi 01',
|
||||
platformId: RESOURCE_ID,
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-30T18:16:00Z',
|
||||
canonicalIdentity: {
|
||||
displayName: 'ESXi 01',
|
||||
hostname: 'esxi-01.lab.local',
|
||||
platformId: RESOURCE_ID,
|
||||
},
|
||||
agent: {
|
||||
hostname: 'esxi-01.lab.local',
|
||||
platform: 'VMware ESXi',
|
||||
uptimeSeconds: 604800,
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
vcenterHost: 'vc.lab.local',
|
||||
managedObjectId: 'host-21',
|
||||
entityType: 'HostSystem',
|
||||
overallStatus: 'green',
|
||||
activeAlarmCount: 1,
|
||||
activeAlarmSummary: 'Host fan degraded',
|
||||
recentTaskCount: 1,
|
||||
recentTaskSummary: 'Enter maintenance mode (success)',
|
||||
},
|
||||
platformData: {
|
||||
sources: ['vmware-vsphere'],
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const facetsMatch = requestUrl.pathname.match(/^\/api\/resources\/([^/]+)\/facets$/);
|
||||
if (!facetsMatch) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === `/api/resources/${RESOURCE_ID_ENCODED}/facets`) {
|
||||
facetRequestUrls.push(requestUrl.toString());
|
||||
const bundle =
|
||||
requestUrl.searchParams.get('kind') === 'activity' &&
|
||||
requestUrl.searchParams.get('sourceAdapter') === 'vmware_adapter'
|
||||
? filteredFacetBundle
|
||||
: unfilteredFacetBundle;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(bundle),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
facetRequestUrls.push(requestUrl.toString());
|
||||
const resourceId = decodeURIComponent(facetsMatch[1]);
|
||||
const bundle =
|
||||
requestUrl.searchParams.get('kind') === 'activity' &&
|
||||
requestUrl.searchParams.get('sourceAdapter') === 'vmware_adapter'
|
||||
? buildFilteredFacetBundle(resourceId)
|
||||
: buildUnfilteredFacetBundle(resourceId);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(bundle),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/audit/actions**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
actionAuditRequestUrls.push(requestUrl.toString());
|
||||
const resourceId = requestUrl.searchParams.get('resourceId') || '';
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(
|
||||
requestUrl.searchParams.get('resourceId') === RESOURCE_ID
|
||||
? actionAuditBundle
|
||||
resourceId
|
||||
? buildActionAuditBundle(resourceId)
|
||||
: { available: true, count: 0, audits: [] },
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/intelligence**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
if (requestUrl.searchParams.get('resource_id') !== RESOURCE_ID) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
timestamp: '2026-03-30T18:16:00Z',
|
||||
overall_health: {
|
||||
score: 81,
|
||||
grade: 'B',
|
||||
trend: 'stable',
|
||||
factors: [],
|
||||
prediction: 'Infrastructure posture is stable.',
|
||||
},
|
||||
findings_count: {
|
||||
critical: 0,
|
||||
warning: 0,
|
||||
watch: 0,
|
||||
info: 0,
|
||||
total: 0,
|
||||
},
|
||||
predictions_count: 0,
|
||||
recent_changes_count: 0,
|
||||
recent_changes: [],
|
||||
learning: {
|
||||
resources_with_knowledge: 0,
|
||||
total_notes: 0,
|
||||
resources_with_baselines: 0,
|
||||
patterns_detected: 0,
|
||||
correlations_learned: 0,
|
||||
incidents_tracked: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await page.goto('/vmware', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('[data-testid="vmware-page"]')).toBeVisible();
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
resource_id: RESOURCE_ID,
|
||||
resource_name: 'ESXi 01',
|
||||
resource_type: 'agent',
|
||||
health: {
|
||||
score: 81,
|
||||
grade: 'B',
|
||||
trend: 'stable',
|
||||
factors: [],
|
||||
prediction: 'VMware host activity is readable through shared history.',
|
||||
},
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
correlations: [],
|
||||
recent_changes: [],
|
||||
note_count: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page
|
||||
.getByRole('button', { name: 'Expand details for esxi-01.lab.local' })
|
||||
.click();
|
||||
|
||||
await page.goto(
|
||||
`/infrastructure?source=vmware-vsphere&resource=${encodeURIComponent(RESOURCE_ID)}`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
},
|
||||
);
|
||||
const drawer = page.getByRole('region', { name: 'esxi-01.lab.local' });
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
const historySection = page.getByTestId('resource-change-history-section');
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
await expect(page.locator('#infra-source-filter')).toHaveValue('vmware-vsphere');
|
||||
await expect(
|
||||
page.locator('div[title="ESXi 01"]').filter({ hasText: 'ESXi 01' }).first(),
|
||||
).toBeVisible();
|
||||
await expect(historySection).toBeVisible();
|
||||
await expect(historySection.getByText('Enter maintenance mode (success)')).toBeVisible();
|
||||
await expect(
|
||||
historySection.getByText('Pulse inferred elevated host risk from alarm churn'),
|
||||
).toBeVisible();
|
||||
await expect(historySection.getByText('Activity', { exact: true })).toBeVisible();
|
||||
await expect(historySection.getByText('VMware adapter', { exact: true })).toBeVisible();
|
||||
|
||||
const actionHistorySection = page.getByTestId('resource-action-history-section');
|
||||
await expect(actionHistorySection).toBeVisible();
|
||||
await expect(actionHistorySection.getByText('Actions 2')).toBeVisible();
|
||||
await expect(actionHistorySection.getByText('Verification confirmed')).toBeVisible();
|
||||
await expect(
|
||||
actionHistorySection.getByText('vCenter reported the host entering maintenance mode.'),
|
||||
actionHistorySection.getByText('Confirmed by independent observer'),
|
||||
).toBeVisible();
|
||||
await expect(actionHistorySection.getByText('Refused', { exact: true })).toBeVisible();
|
||||
await expect(actionHistorySection.getByText('Execution refused')).toBeVisible();
|
||||
await expect(actionHistorySection.getByText('Resource remediation locked')).toBeVisible();
|
||||
await expect(actionHistorySection.getByText('Refused before dispatch')).toBeVisible();
|
||||
await expect(
|
||||
actionHistorySection.getByText('Resource remediation locked'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
actionHistorySection.getByText(
|
||||
'Pulse refused the action before dispatch because this resource is locked against automatic remediation.',
|
||||
|
|
@ -393,7 +300,6 @@ test.describe('VMware resource history drawer', () => {
|
|||
facetRequestUrls.some((url) => {
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
parsed.pathname === `/api/resources/${RESOURCE_ID_ENCODED}/facets` &&
|
||||
parsed.searchParams.get('limit') === '25' &&
|
||||
parsed.searchParams.get('kind') === 'activity' &&
|
||||
parsed.searchParams.get('sourceAdapter') === 'vmware_adapter'
|
||||
|
|
@ -409,7 +315,7 @@ test.describe('VMware resource history drawer', () => {
|
|||
const parsed = new URL(url);
|
||||
return (
|
||||
parsed.pathname === '/api/audit/actions' &&
|
||||
parsed.searchParams.get('resourceId') === RESOURCE_ID &&
|
||||
Boolean(parsed.searchParams.get('resourceId')) &&
|
||||
parsed.searchParams.get('limit') === '5'
|
||||
);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test as base } from '@playwright/test';
|
||||
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { createAuthenticatedStorageState } from './helpers';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -35,6 +34,9 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
}, { scope: 'worker' }],
|
||||
});
|
||||
|
||||
// Mention candidates come from live websocket state, so the assertions pin
|
||||
// the mock scenario's vSphere inventory (esxi hosts and warehouse VMs)
|
||||
// instead of stubbed REST payloads from the retired /infrastructure IA.
|
||||
test.describe('VMware AI chat mentions', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
|
|
@ -47,208 +49,52 @@ test.describe('VMware AI chat mentions', () => {
|
|||
const url = new URL(route.request().url());
|
||||
const method = route.request().method();
|
||||
if (method === 'GET' && url.pathname === '/api/vmware/connections') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
unexpectedVMwareRequests.push(`${method} ${url.pathname}`);
|
||||
await route.abort();
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ running: true, engine: 'test' }),
|
||||
});
|
||||
});
|
||||
await page.goto('/vmware', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('[data-testid="vmware-page"]')).toBeVisible();
|
||||
|
||||
await page.route('**/api/ai/sessions', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/settings', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
model: 'openai:gpt-4o-mini',
|
||||
chat_model: '',
|
||||
control_level: 'read_only',
|
||||
discovery_enabled: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/models', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
models: [{ id: 'openai:gpt-4o-mini', name: 'GPT-4o mini' }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/resources**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
if (requestUrl.pathname !== '/api/resources') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'agent-vmware-host-1',
|
||||
type: 'agent',
|
||||
name: 'esxi-01.lab.local',
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-30T09:00:00Z',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
canonicalIdentity: {
|
||||
displayName: 'ESXi 01',
|
||||
hostname: 'esxi-01.lab.local',
|
||||
primaryId: 'vmware:vc-1:host:host-101',
|
||||
},
|
||||
agent: {
|
||||
agentId: 'vc-1:host:host-101',
|
||||
hostname: 'esxi-01.lab.local',
|
||||
platform: 'VMware ESXi',
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
managedObjectId: 'host-101',
|
||||
entityType: 'host',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vm-vmware-1',
|
||||
type: 'vm',
|
||||
name: 'app-01',
|
||||
status: 'running',
|
||||
lastSeen: '2026-03-30T09:00:00Z',
|
||||
parentId: 'agent-vmware-host-1',
|
||||
parentName: 'esxi-01.lab.local',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
canonicalIdentity: {
|
||||
displayName: 'App 01',
|
||||
hostname: 'app-01.internal',
|
||||
primaryId: 'vmware:vc-1:vm:vm-201',
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
managedObjectId: 'vm-201',
|
||||
entityType: 'vm',
|
||||
runtimeHostName: 'esxi-01.lab.local',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'storage-vmware-1',
|
||||
type: 'storage',
|
||||
name: 'nvme-primary',
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-30T09:00:00Z',
|
||||
parentName: 'Lab VC',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
canonicalIdentity: {
|
||||
displayName: 'NVMe Primary',
|
||||
primaryId: 'vmware:vc-1:datastore:datastore-11',
|
||||
},
|
||||
storage: {
|
||||
type: 'vmfs',
|
||||
platform: 'vmware-vsphere',
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
managedObjectId: 'datastore-11',
|
||||
entityType: 'datastore',
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const resourcesLoaded = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/resources' && response.request().method() === 'GET';
|
||||
});
|
||||
|
||||
await page.goto('/infrastructure?source=vmware-vsphere', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForURL(/\/infrastructure\?source=vmware-vsphere/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
await resourcesLoaded;
|
||||
|
||||
await page.getByRole('button', { name: 'Expand Pulse Assistant' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Pulse Assistant', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Ask Pulse Assistant about vSphere' }).click();
|
||||
await expect(
|
||||
page.getByPlaceholder('Ask about your infrastructure...'),
|
||||
).toBeVisible();
|
||||
|
||||
const textarea = page.getByPlaceholder('Ask about your infrastructure...');
|
||||
const mentionListbox = page.getByRole('listbox', { name: 'Assistant resources' });
|
||||
|
||||
// ESXi hosts surface as shared agent mention targets.
|
||||
// First websocket state frame can lag on a freshly booted backend, and
|
||||
// the candidate list refreshes as state arrives.
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially('@esxi');
|
||||
|
||||
const mentionSurface = page.locator('[data-mention-autocomplete]');
|
||||
const hostOption = mentionSurface.getByRole('button', { name: /ESXi 01 agent/ }).first();
|
||||
await expect(mentionSurface.getByText('Resources')).toBeVisible();
|
||||
await expect(hostOption).toBeVisible();
|
||||
await expect(mentionSurface).toContainText('agent');
|
||||
await expect(mentionListbox).toBeVisible({ timeout: 30_000 });
|
||||
const hostOption = mentionListbox
|
||||
.getByRole('option', { name: /esxi-01\.lab\.local: agent/ })
|
||||
.first();
|
||||
await expect(hostOption).toBeVisible({ timeout: 30_000 });
|
||||
await hostOption.click();
|
||||
await expect(textarea).toHaveValue('@ESXi 01 ');
|
||||
await expect(textarea).toHaveValue('@esxi-01.lab.local ');
|
||||
|
||||
// API-backed vSphere VMs use the same shared mention contract, carrying
|
||||
// their runtime host as the mention node.
|
||||
await textarea.fill('');
|
||||
await textarea.pressSequentially('@app');
|
||||
|
||||
await expect(mentionSurface.getByText('Resources')).toBeVisible();
|
||||
await expect(mentionSurface.getByRole('button', { name: /App 01/ })).toBeVisible();
|
||||
await expect(mentionSurface).toContainText('vm');
|
||||
await expect(mentionSurface).toContainText('esxi-01.lab.local');
|
||||
await mentionSurface.getByRole('button', { name: /App 01/ }).click();
|
||||
await expect(textarea).toHaveValue('@App 01 ');
|
||||
|
||||
await textarea.fill('');
|
||||
await textarea.pressSequentially('@nvme');
|
||||
await expect(mentionSurface.getByRole('button', { name: /NVMe Primary/ })).toBeVisible();
|
||||
await expect(mentionSurface).toContainText('storage');
|
||||
await expect(mentionSurface).toContainText('Lab VC');
|
||||
await mentionSurface.getByRole('button', { name: /NVMe Primary/ }).click();
|
||||
await expect(textarea).toHaveValue('@NVMe Primary ');
|
||||
await textarea.pressSequentially('@warehouse');
|
||||
await expect(mentionListbox).toBeVisible();
|
||||
const vmOption = mentionListbox
|
||||
.getByRole('option', { name: /warehouse-api-01: vm on esxi-01\.lab\.local/ })
|
||||
.first();
|
||||
await expect(vmOption).toBeVisible({ timeout: 30_000 });
|
||||
await vmOption.click();
|
||||
await expect(textarea).toHaveValue('@warehouse-api-01 ');
|
||||
|
||||
expect(unexpectedVMwareRequests).toEqual([]);
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
// Viewport-only: full-page capture can hang while the assistant panel
|
||||
// animates, and the screenshot is an artifact rather than an assertion.
|
||||
await page.screenshot({ path: SCREENSHOT_PATH });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import { createAuthenticatedStorageState } from './helpers';
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCREENSHOT_PATH = '/tmp/vmware-resource-detail-drawer.png';
|
||||
const RESOURCE_ID = 'vc-1:vm:vm-201';
|
||||
const RESOURCE_ID_ENCODED = encodeURIComponent(RESOURCE_ID);
|
||||
|
||||
type WorkerFixtures = {
|
||||
authStorageStatePath: string;
|
||||
|
|
@ -37,10 +35,13 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
}, { scope: 'worker' }],
|
||||
});
|
||||
|
||||
// The retired /infrastructure resource deep-link was replaced by the vSphere
|
||||
// platform page: expanding a VM row opens the shared guest drawer, which
|
||||
// carries the read-only vCenter placement context from live websocket state.
|
||||
test.describe('VMware resource detail drawer', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test('surfaces VMware read-only context through the shared drawer path', async ({ page }) => {
|
||||
test('surfaces VMware read-only context through the shared guest drawer', async ({ page }) => {
|
||||
let unexpectedVmwareApiCall: string | null = null;
|
||||
|
||||
await page.route('**/api/vmware/**', async (route) => {
|
||||
|
|
@ -48,145 +49,32 @@ test.describe('VMware resource detail drawer', () => {
|
|||
await route.abort();
|
||||
});
|
||||
|
||||
await page.route('**/api/resources**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
await page.goto('/vmware', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('[data-testid="vmware-page"]')).toBeVisible();
|
||||
|
||||
if (requestUrl.pathname === '/api/resources') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: RESOURCE_ID,
|
||||
type: 'vm',
|
||||
name: 'app-01.lab.local',
|
||||
displayName: 'App 01',
|
||||
platformId: RESOURCE_ID,
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
status: 'running',
|
||||
lastSeen: '2026-03-30T18:25:00Z',
|
||||
canonicalIdentity: {
|
||||
displayName: 'App 01',
|
||||
hostname: 'app-01.lab.local',
|
||||
platformId: RESOURCE_ID,
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
vcenterHost: 'vc.lab.local',
|
||||
managedObjectId: 'vm-201',
|
||||
entityType: 'VirtualMachine',
|
||||
overallStatus: 'green',
|
||||
powerState: 'poweredOn',
|
||||
datacenterName: 'Lab DC',
|
||||
clusterName: 'Compute Cluster',
|
||||
resourcePoolName: 'Production',
|
||||
runtimeHostName: 'esxi-01.lab.local',
|
||||
datastoreNames: ['shared-vsan'],
|
||||
guestOsFamily: 'ubuntu64Guest',
|
||||
guestHostname: 'app-01.lab.local',
|
||||
guestIpAddresses: ['192.0.2.50'],
|
||||
activeAlarmCount: 1,
|
||||
activeAlarmSummary: 'Host fan degraded',
|
||||
recentTaskCount: 1,
|
||||
recentTaskSummary: 'Create snapshot (success)',
|
||||
snapshotCount: 2,
|
||||
},
|
||||
platformData: {
|
||||
sources: ['vmware-vsphere'],
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// First websocket state frame can lag on a freshly booted backend.
|
||||
const expandButton = page.getByRole('button', { name: 'Expand warehouse-api-01' });
|
||||
await expect(expandButton).toBeVisible({ timeout: 30_000 });
|
||||
await expandButton.click();
|
||||
|
||||
if (requestUrl.pathname === `/api/resources/${RESOURCE_ID_ENCODED}/facets`) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
counts: {
|
||||
recentChanges: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/intelligence**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
resource_id: RESOURCE_ID,
|
||||
resource_name: 'App 01',
|
||||
resource_type: 'vm',
|
||||
health: {
|
||||
score: 88,
|
||||
grade: 'B',
|
||||
trend: 'stable',
|
||||
factors: [],
|
||||
prediction: 'VMware placement and signal context is available on the shared drawer.',
|
||||
},
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
correlations: [],
|
||||
recent_changes: [],
|
||||
note_count: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/infrastructure?source=vmware-vsphere&resource=${encodeURIComponent(RESOURCE_ID)}`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
},
|
||||
);
|
||||
|
||||
const vmwareSection = page.getByTestId('resource-vmware-details-section');
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
await expect(page.locator('#infra-source-filter')).toHaveValue('vmware-vsphere');
|
||||
const drawer = page.getByRole('region', { name: 'warehouse-api-01' });
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(
|
||||
page.locator('div[title="App 01"]').filter({ hasText: 'App 01' }).first(),
|
||||
drawer.getByRole('heading', { level: 2, name: 'warehouse-api-01' }),
|
||||
).toBeVisible();
|
||||
await expect(vmwareSection).toBeVisible();
|
||||
await expect(vmwareSection).toContainText(
|
||||
'Lab VC · Read-only vCenter context · 2 snapshots · 1 alarm · 1 task',
|
||||
);
|
||||
|
||||
await vmwareSection.getByRole('button', { name: 'Show vSphere' }).click();
|
||||
// Placement context comes from the vCenter inventory, rendered read-only.
|
||||
await expect(drawer.getByRole('heading', { name: 'vSphere' })).toBeVisible();
|
||||
await expect(drawer.getByText('Lab vCenter', { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByText('Primary DC', { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByText('Production Cluster', { exact: true })).toBeVisible();
|
||||
|
||||
await expect(vmwareSection.getByText('State', { exact: true })).toBeVisible();
|
||||
await expect(vmwareSection.getByText('Placement', { exact: true })).toBeVisible();
|
||||
await expect(vmwareSection.getByText('Guest', { exact: true })).toBeVisible();
|
||||
await expect(vmwareSection.getByText('Signals', { exact: true })).toBeVisible();
|
||||
await expect(vmwareSection.getByText('vc.lab.local')).toBeVisible();
|
||||
await expect(vmwareSection.getByText('Compute Cluster')).toBeVisible();
|
||||
await expect(vmwareSection.getByText('esxi-01.lab.local')).toBeVisible();
|
||||
await expect(vmwareSection.getByText('ubuntu64Guest')).toBeVisible();
|
||||
await expect(vmwareSection.getByText('Create snapshot (success)')).toBeVisible();
|
||||
await expect(vmwareSection.getByText('Host fan degraded')).toBeVisible();
|
||||
await expect(vmwareSection.getByText('2 snapshots', { exact: true })).toBeVisible();
|
||||
// Host placement and guest identity surface alongside.
|
||||
await expect(drawer.getByText('esxi-01.lab.local')).toBeVisible();
|
||||
await expect(drawer.getByRole('heading', { name: 'Guest Info' })).toBeVisible();
|
||||
|
||||
// Phase-1 VMware stays on shared read paths: the browser never talks to
|
||||
// provider-local vmware endpoints for drawer context.
|
||||
expect(unexpectedVmwareApiCall).toBeNull();
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ import { test as base, expect } from '@playwright/test';
|
|||
import { createAuthenticatedStorageState } from './helpers';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCREENSHOT_PATH = '/tmp/vmware-storage-source-filter.png';
|
||||
|
||||
type WorkerFixtures = {
|
||||
authStorageStatePath: string;
|
||||
};
|
||||
|
||||
const SCREENSHOT_PATH = '/tmp/vmware-storage-source-filter.png';
|
||||
|
||||
const test = base.extend<{}, WorkerFixtures>({
|
||||
storageState: async ({ authStorageStatePath }, use) => {
|
||||
await use(authStorageStatePath);
|
||||
|
|
@ -35,10 +34,14 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
}, { scope: 'worker' }],
|
||||
});
|
||||
|
||||
test.describe('VMware storage source filter', () => {
|
||||
// The retired shared /storage route (with its cross-platform source filter)
|
||||
// was replaced by per-platform storage sections; vSphere datastores live on
|
||||
// the Datastores tab of the vSphere platform page and render live websocket
|
||||
// state from the mock scenario.
|
||||
test.describe('VMware datastores platform section', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test('surfaces VMware datastores on the shared storage route without backup semantics', async ({
|
||||
test('surfaces VMware datastores on the platform storage tab without backup semantics', async ({
|
||||
page,
|
||||
}) => {
|
||||
let unexpectedVmwareApiCall: string | null = null;
|
||||
|
|
@ -48,133 +51,26 @@ test.describe('VMware storage source filter', () => {
|
|||
await route.abort();
|
||||
});
|
||||
|
||||
await page.route('**/api/resources**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
if (requestUrl.pathname !== '/api/resources') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await page.goto('/vmware/storage', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'storage-vmware-1',
|
||||
type: 'storage',
|
||||
name: 'shared-vmfs',
|
||||
displayName: 'shared-vmfs',
|
||||
platformId: 'datastore-11',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-30T20:00:00Z',
|
||||
canonicalIdentity: {
|
||||
displayName: 'shared-vmfs',
|
||||
platformId: 'datastore-11',
|
||||
primaryId: 'vmware:vc-1:datastore:datastore-11',
|
||||
},
|
||||
disk: {
|
||||
total: 4_000 * 1024 * 1024 * 1024,
|
||||
used: 1_500 * 1024 * 1024 * 1024,
|
||||
free: 2_500 * 1024 * 1024 * 1024,
|
||||
current: 37.5,
|
||||
},
|
||||
storage: {
|
||||
platform: 'vmware-vsphere',
|
||||
type: 'vmfs',
|
||||
topology: 'datastore',
|
||||
shared: true,
|
||||
nodes: ['esxi-01.lab.local', 'esxi-02.lab.local'],
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
vcenterHost: 'vc.lab.local',
|
||||
managedObjectId: 'datastore-11',
|
||||
entityType: 'datastore',
|
||||
datastoreType: 'VMFS',
|
||||
datastoreAccessible: true,
|
||||
multipleHostAccess: true,
|
||||
datastoreUrl: '/vmfs/volumes/shared-vmfs',
|
||||
},
|
||||
platformData: {
|
||||
sources: ['vmware-vsphere'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'storage-truenas-1',
|
||||
type: 'storage',
|
||||
name: 'tank',
|
||||
displayName: 'tank',
|
||||
parentId: 'truenas-main',
|
||||
parentName: 'truenas-main',
|
||||
platformId: 'truenas-1',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
sources: ['truenas'],
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-30T20:00:00Z',
|
||||
canonicalIdentity: {
|
||||
displayName: 'tank',
|
||||
platformId: 'truenas-1',
|
||||
},
|
||||
disk: {
|
||||
total: 2_000 * 1024 * 1024 * 1024,
|
||||
used: 1_000 * 1024 * 1024 * 1024,
|
||||
free: 1_000 * 1024 * 1024 * 1024,
|
||||
current: 50,
|
||||
},
|
||||
storage: {
|
||||
platform: 'truenas',
|
||||
type: 'zfs-pool',
|
||||
topology: 'pool',
|
||||
isZfs: true,
|
||||
},
|
||||
platformData: {
|
||||
sources: ['truenas'],
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/storage?source=vmware-vsphere', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/\/storage\?source=vmware-vsphere/);
|
||||
await expect(page.locator('#storage-source-filter')).toHaveValue('vmware-vsphere');
|
||||
|
||||
const sourceOptions = await page.locator('#storage-source-filter option').evaluateAll((options) =>
|
||||
options.map((option) => ({
|
||||
value: option.getAttribute('value'),
|
||||
label: option.textContent?.trim(),
|
||||
})),
|
||||
);
|
||||
expect(sourceOptions).toEqual([
|
||||
{ value: 'all', label: 'All Sources' },
|
||||
{ value: 'truenas', label: 'TrueNAS' },
|
||||
{ value: 'vmware-vsphere', label: 'vSphere' },
|
||||
]);
|
||||
await expect(page).toHaveURL(/\/vmware\/storage/);
|
||||
|
||||
const storageTable = page.locator('table').first();
|
||||
await expect(storageTable).toContainText('shared-vmfs');
|
||||
await expect(storageTable).toContainText('vSphere');
|
||||
await expect(storageTable).toContainText('Datastore');
|
||||
await expect(storageTable).toContainText('esxi-01.lab.local');
|
||||
await expect(storageTable).not.toContainText('tank');
|
||||
// The mock scenario's nvme-primary datastore carries type, host, and
|
||||
// consumer context on the row. First websocket state frame can lag on a
|
||||
// freshly booted backend, so the first data assertion waits longer.
|
||||
const datastoreRow = page.locator('tr').filter({ hasText: 'nvme-primary' }).first();
|
||||
await expect(datastoreRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole('textbox', { name: 'Search vSphere datastores' })).toBeVisible();
|
||||
await expect(datastoreRow).toContainText('VMFS');
|
||||
await expect(datastoreRow).toContainText('esxi-01.lab.local');
|
||||
|
||||
// Phase-1 VMware storage is read-only capacity context: no Proxmox-style
|
||||
// backup semantics may leak onto the datastore table.
|
||||
await expect(storageTable).not.toContainText('Backup Target');
|
||||
await expect(storageTable).not.toContainText('Protected');
|
||||
// TrueNAS entities stay on their own platform page.
|
||||
await expect(storageTable).not.toContainText('tank');
|
||||
|
||||
expect(unexpectedVmwareApiCall).toBeNull();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import { createAuthenticatedStorageState } from './helpers';
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCREENSHOT_PATH = '/tmp/vmware-phase1-exclusion-integrity.png';
|
||||
const RESOURCE_ID = 'vc-1:vm:vm-201';
|
||||
const RESOURCE_ID_ENCODED = encodeURIComponent(RESOURCE_ID);
|
||||
|
||||
type WorkerFixtures = {
|
||||
authStorageStatePath: string;
|
||||
|
|
@ -25,7 +23,7 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
'..',
|
||||
'tmp',
|
||||
'playwright-auth',
|
||||
`vmware-phase1-exclusion-integrity-${workerInfo.project.name}.json`,
|
||||
`vmware-phase1-exclusion-${workerInfo.project.name}.json`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
|
||||
await createAuthenticatedStorageState(browser, storageStatePath);
|
||||
|
|
@ -37,6 +35,10 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
}, { scope: 'worker' }],
|
||||
});
|
||||
|
||||
// Phase-1 VMware coverage is read-only vCenter context on the vSphere
|
||||
// platform page. The guest drawer for an API-backed VM must not offer
|
||||
// provider-local admin actions or recovery cross-links, and the browser
|
||||
// must stay off vmware/recovery provider endpoints while rendering it.
|
||||
test.describe('VMware phase-1 exclusion integrity', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
|
|
@ -56,121 +58,28 @@ test.describe('VMware phase-1 exclusion integrity', () => {
|
|||
await route.abort();
|
||||
});
|
||||
|
||||
await page.route('**/api/resources**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
await page.goto('/vmware', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('[data-testid="vmware-page"]')).toBeVisible();
|
||||
|
||||
if (requestUrl.pathname === '/api/resources') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: RESOURCE_ID,
|
||||
type: 'vm',
|
||||
name: 'app-01.lab.local',
|
||||
displayName: 'App 01',
|
||||
platformId: RESOURCE_ID,
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
status: 'running',
|
||||
lastSeen: '2026-03-30T21:10:00Z',
|
||||
canonicalIdentity: {
|
||||
displayName: 'App 01',
|
||||
hostname: 'app-01.lab.local',
|
||||
platformId: RESOURCE_ID,
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
vcenterHost: 'vc.lab.local',
|
||||
managedObjectId: 'vm-201',
|
||||
entityType: 'VirtualMachine',
|
||||
overallStatus: 'green',
|
||||
powerState: 'poweredOn',
|
||||
runtimeHostName: 'esxi-01.lab.local',
|
||||
guestOsFamily: 'ubuntu64Guest',
|
||||
snapshotCount: 2,
|
||||
},
|
||||
platformData: {
|
||||
sources: ['vmware-vsphere'],
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// First websocket state frame can lag on a freshly booted backend.
|
||||
const expandButton = page.getByRole('button', { name: 'Expand warehouse-api-01' });
|
||||
await expect(expandButton).toBeVisible({ timeout: 30_000 });
|
||||
await expandButton.click();
|
||||
|
||||
if (requestUrl.pathname === `/api/resources/${RESOURCE_ID_ENCODED}/facets`) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
counts: {
|
||||
recentChanges: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const drawer = page.getByRole('region', { name: 'warehouse-api-01' });
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/intelligence**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
resource_id: RESOURCE_ID,
|
||||
resource_name: 'App 01',
|
||||
resource_type: 'vm',
|
||||
health: {
|
||||
score: 90,
|
||||
grade: 'A',
|
||||
trend: 'stable',
|
||||
factors: [],
|
||||
prediction: 'Phase-1 VMware context remains read-only.',
|
||||
},
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
correlations: [],
|
||||
recent_changes: [],
|
||||
note_count: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/infrastructure?source=vmware-vsphere&resource=${encodeURIComponent(RESOURCE_ID)}`,
|
||||
{
|
||||
waitUntil: 'domcontentloaded',
|
||||
},
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
await expect(page.getByTestId('resource-vmware-details-section')).toContainText(
|
||||
'Read-only vCenter context',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Show access' }).click();
|
||||
// The action surface points at agent onboarding instead of offering
|
||||
// native lifecycle controls for an API-backed VM.
|
||||
await expect(
|
||||
drawer.getByRole('link', { name: 'Add agent for AI actions' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: /Open related recovery/i })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: /Open in Recovery/i })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Restart$/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Stop$/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Shutdown$/ })).toHaveCount(0);
|
||||
await expect(drawer.getByRole('button', { name: /^Restart$/ })).toHaveCount(0);
|
||||
await expect(drawer.getByRole('button', { name: /^Stop$/ })).toHaveCount(0);
|
||||
await expect(drawer.getByRole('button', { name: /^Shutdown$/ })).toHaveCount(0);
|
||||
|
||||
expect(unexpectedVmwareApiCall).toBeNull();
|
||||
expect(unexpectedRecoveryApiCall).toBeNull();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test as base } from '@playwright/test';
|
||||
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { createAuthenticatedStorageState } from './helpers';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -35,314 +34,67 @@ const test = base.extend<{}, WorkerFixtures>({
|
|||
}, { scope: 'worker' }],
|
||||
});
|
||||
|
||||
const encodeSSE = (events: unknown[]) =>
|
||||
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('');
|
||||
|
||||
// The assistant transport moved from the retired /api/ai/chat SSE endpoint
|
||||
// to session-scoped message turns, so scripting the whole exchange through
|
||||
// route stubs would re-implement the streaming protocol inside the spec.
|
||||
// Instead this observes a real turn against the mock assistant: the mention
|
||||
// payload must travel the shared session path, and the browser must stay off
|
||||
// provider-local vmware endpoints for the whole exchange.
|
||||
test.describe('VMware AI chat read recovery', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test('keeps VMware read recovery on shared assistant paths', async ({ page }) => {
|
||||
test('keeps VMware chat turns on shared assistant paths', async ({ page }) => {
|
||||
const unexpectedVMwareRequests: string[] = [];
|
||||
const chatPayloads: Array<Record<string, unknown>> = [];
|
||||
const sessions: Array<{ id: string; title: string; created_at: string; updated_at: string; message_count: number }> = [];
|
||||
const createdSession = {
|
||||
id: 'sess-vmware-read-1',
|
||||
title: '',
|
||||
created_at: '2026-03-31T08:00:00Z',
|
||||
updated_at: '2026-03-31T08:00:00Z',
|
||||
message_count: 0,
|
||||
};
|
||||
|
||||
await page.route('**/api/vmware/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const method = route.request().method();
|
||||
if (method === 'GET' && url.pathname === '/api/vmware/connections') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
unexpectedVMwareRequests.push(`${method} ${url.pathname}`);
|
||||
await route.abort();
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ running: true, engine: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/sessions', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
sessions.splice(0, sessions.length, createdSession);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createdSession),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(sessions),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/settings', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
model: 'openai:gpt-4o-mini',
|
||||
chat_model: '',
|
||||
control_level: 'read_only',
|
||||
discovery_enabled: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/models', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
models: [{ id: 'openai:gpt-4o-mini', name: 'GPT-4o mini' }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/ai/chat', async (route) => {
|
||||
const payload = route.request().postDataJSON() as Record<string, unknown>;
|
||||
chatPayloads.push(payload);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
body: encodeSSE([
|
||||
{ type: 'thinking', data: 'Checking the canonical VMware read path.' },
|
||||
{
|
||||
type: 'tool_start',
|
||||
data: {
|
||||
id: 'tool-read-1',
|
||||
name: 'pulse_read',
|
||||
input: '{"action":"logs","resource_id":"vm-vmware-1"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool_end',
|
||||
data: {
|
||||
id: 'tool-read-1',
|
||||
name: 'pulse_read',
|
||||
input: '{"action":"logs","resource_id":"vm-vmware-1"}',
|
||||
output:
|
||||
'blocked: native VMware logs are unavailable on resource_id; auto-recover -> pulse_query action=get resource_id="vm-vmware-1"',
|
||||
success: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool_start',
|
||||
data: {
|
||||
id: 'tool-query-1',
|
||||
name: 'pulse_query',
|
||||
input: '{"action":"get","resource_type":"vm","resource_id":"vm-vmware-1"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool_end',
|
||||
data: {
|
||||
id: 'tool-query-1',
|
||||
name: 'pulse_query',
|
||||
input: '{"action":"get","resource_type":"vm","resource_id":"vm-vmware-1"}',
|
||||
output:
|
||||
'vmware status ok: App 01 is running on esxi-01.lab.local with 1 active alarm, 1 snapshot, and recent activity available',
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content',
|
||||
data:
|
||||
'App 01 is API-backed through vCenter. I could not read native guest logs on the VMware phase-1 path, so I switched to the shared resource read path and inspected status, alarms, snapshot visibility, and recent activity instead.',
|
||||
},
|
||||
{ type: 'done' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/resources**', async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
if (requestUrl.pathname !== '/api/resources') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'agent-vmware-host-1',
|
||||
type: 'agent',
|
||||
name: 'esxi-01.lab.local',
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-30T09:00:00Z',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
canonicalIdentity: {
|
||||
displayName: 'ESXi 01',
|
||||
hostname: 'esxi-01.lab.local',
|
||||
primaryId: 'vmware:vc-1:host:host-101',
|
||||
},
|
||||
agent: {
|
||||
agentId: 'vc-1:host:host-101',
|
||||
hostname: 'esxi-01.lab.local',
|
||||
platform: 'VMware ESXi',
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
managedObjectId: 'host-101',
|
||||
entityType: 'host',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vm-vmware-1',
|
||||
type: 'vm',
|
||||
name: 'app-01',
|
||||
status: 'running',
|
||||
lastSeen: '2026-03-30T09:00:00Z',
|
||||
parentId: 'agent-vmware-host-1',
|
||||
parentName: 'esxi-01.lab.local',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
canonicalIdentity: {
|
||||
displayName: 'App 01',
|
||||
hostname: 'app-01.internal',
|
||||
primaryId: 'vmware:vc-1:vm:vm-201',
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
managedObjectId: 'vm-201',
|
||||
entityType: 'vm',
|
||||
runtimeHostName: 'esxi-01.lab.local',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'storage-vmware-1',
|
||||
type: 'storage',
|
||||
name: 'nvme-primary',
|
||||
status: 'online',
|
||||
lastSeen: '2026-03-30T09:00:00Z',
|
||||
parentName: 'Lab VC',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware-vsphere'],
|
||||
canonicalIdentity: {
|
||||
displayName: 'NVMe Primary',
|
||||
primaryId: 'vmware:vc-1:datastore:datastore-11',
|
||||
},
|
||||
storage: {
|
||||
type: 'vmfs',
|
||||
platform: 'vmware-vsphere',
|
||||
},
|
||||
vmware: {
|
||||
connectionId: 'vc-1',
|
||||
connectionName: 'Lab VC',
|
||||
managedObjectId: 'datastore-11',
|
||||
entityType: 'datastore',
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const resourcesLoaded = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/resources' && response.request().method() === 'GET';
|
||||
});
|
||||
|
||||
await page.goto('/infrastructure?source=vmware-vsphere', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForURL(/\/infrastructure\?source=vmware-vsphere/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
await resourcesLoaded;
|
||||
|
||||
await page.getByRole('button', { name: 'Expand Pulse Assistant' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Pulse Assistant', exact: true })).toBeVisible();
|
||||
await page.goto('/vmware', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('[data-testid="vmware-page"]')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Ask Pulse Assistant about vSphere' }).click();
|
||||
const textarea = page.getByPlaceholder('Ask about your infrastructure...');
|
||||
await expect(textarea).toBeVisible();
|
||||
|
||||
// First websocket state frame can lag on a freshly booted backend.
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially('@app');
|
||||
await textarea.pressSequentially('@warehouse');
|
||||
const mentionListbox = page.getByRole('listbox', { name: 'Assistant resources' });
|
||||
await expect(mentionListbox).toBeVisible({ timeout: 30_000 });
|
||||
await mentionListbox
|
||||
.getByRole('option', { name: /warehouse-api-01: vm on esxi-01\.lab\.local/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(textarea).toHaveValue('@warehouse-api-01 ');
|
||||
|
||||
const mentionSurface = page.locator('[data-mention-autocomplete]');
|
||||
await expect(mentionSurface.getByText('Resources')).toBeVisible();
|
||||
await expect(mentionSurface.getByRole('button', { name: /App 01/ })).toBeVisible();
|
||||
await mentionSurface.getByRole('button', { name: /App 01/ }).click();
|
||||
await expect(textarea).toHaveValue('@App 01 ');
|
||||
|
||||
await textarea.fill('@App 01 show me logs');
|
||||
await textarea.fill('@warehouse-api-01 show me recent status');
|
||||
await textarea.press('Enter');
|
||||
|
||||
// Chat turns ride the shared assistant transport (websocket-backed since
|
||||
// the assistant modernization, so REST route interception cannot observe
|
||||
// the payload). The proof is the rendered exchange: the mention-carrying
|
||||
// user message lands in the conversation and the mock assistant answers
|
||||
// the turn, all without the browser touching provider-local vmware
|
||||
// endpoints.
|
||||
await expect(
|
||||
page.getByText(
|
||||
'App 01 is API-backed through vCenter. I could not read native guest logs on the VMware phase-1 path, so I switched to the shared resource read path and inspected status, alarms, snapshot visibility, and recent activity instead.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'blocked: native VMware logs are unavailable on resource_id; auto-recover -> pulse_query action=get resource_id="vm-vmware-1"',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'vmware status ok: App 01 is running on esxi-01.lab.local with 1 active alarm, 1 snapshot, and recent activity available',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
expect(chatPayloads).toHaveLength(1);
|
||||
expect(chatPayloads[0]).toMatchObject({
|
||||
prompt: '@App 01 show me logs',
|
||||
session_id: createdSession.id,
|
||||
mentions: [
|
||||
{
|
||||
id: 'vm-vmware-1',
|
||||
name: 'App 01',
|
||||
type: 'vm',
|
||||
node: 'esxi-01.lab.local',
|
||||
},
|
||||
],
|
||||
page.getByText('@warehouse-api-01 show me recent status').first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('Pulse mock Assistant').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(unexpectedVMwareRequests).toEqual([]);
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
// Viewport-only: full-page capture can hang while the assistant panel
|
||||
// animates, and the screenshot is an artifact rather than an assertion.
|
||||
await page.screenshot({ path: SCREENSHOT_PATH });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue