mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
Add unit tests for alert override identity, threshold mutation models, patrol autonomy
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getGuestOverrideIdentity,
|
||||
guestOverrideIdCandidates,
|
||||
guestOverrideStorageId,
|
||||
normalizeGuestOverrideKey,
|
||||
} from '../guestOverrideIdentity';
|
||||
|
||||
describe('guestOverrideIdentity', () => {
|
||||
describe('getGuestOverrideIdentity', () => {
|
||||
it('returns undefined when no identity can be derived', () => {
|
||||
expect(getGuestOverrideIdentity({})).toBeUndefined();
|
||||
expect(getGuestOverrideIdentity({ id: 'res-1' })).toBeUndefined();
|
||||
expect(getGuestOverrideIdentity({ node: 'pve' })).toBeUndefined();
|
||||
expect(getGuestOverrideIdentity({ instance: 'cluster-a' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives identity from direct resource fields', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
id: 'res-1',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'res-1',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a string vmid into a positive integer', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({ instance: 'cluster-a', node: 'node-1', vmid: '100' }),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
|
||||
it('trims whitespace on string components', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
id: ' res-1 ',
|
||||
instance: ' cluster-a ',
|
||||
node: ' node-1 ',
|
||||
vmid: ' 100 ',
|
||||
}),
|
||||
).toEqual({ id: 'res-1', instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
|
||||
it('rejects node values that contain a slash and falls through to the next source', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
node: 'cluster/node',
|
||||
proxmox: { node: 'node-1' },
|
||||
instance: 'cluster-a',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['zero number', 0],
|
||||
['negative number', -5],
|
||||
['float number', 1.5],
|
||||
['NaN', Number.NaN],
|
||||
['zero string', '0'],
|
||||
['negative string', '-1'],
|
||||
['float string', '1.5'],
|
||||
['non-numeric string', 'abc'],
|
||||
['empty string', ''],
|
||||
])('rejects an invalid vmid (%s)', (_label, vmid) => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({ instance: 'cluster-a', node: 'node-1', vmid }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Infinity', Number.POSITIVE_INFINITY],
|
||||
['negative Infinity', Number.NEGATIVE_INFINITY],
|
||||
])('rejects an infinite vmid (%s)', (_label, vmid) => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({ instance: 'cluster-a', node: 'node-1', vmid }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves node and vmid from the direct proxmox block when top-level fields are absent', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
instance: 'cluster-a',
|
||||
proxmox: { node: 'node-1', vmid: 100 },
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
|
||||
it('resolves node from proxmox.nodeName as a fallback', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
instance: 'cluster-a',
|
||||
proxmox: { nodeName: 'node-1', vmid: 100 },
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
|
||||
it('resolves identity from platformData.proxmox when proxmox is absent', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
platformData: { proxmox: { instance: 'cluster-a', node: 'node-1', vmid: 100 } },
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
|
||||
it('resolves identity from platformData directly when no proxmox blocks exist', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
platformData: { instance: 'cluster-a', node: 'node-1', vmid: 100 },
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
|
||||
it('prefers direct resource.node over nested sources', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
node: 'direct-node',
|
||||
proxmox: { node: 'proxmox-node', nodeName: 'proxmox-node-name' },
|
||||
platformData: { proxmox: { node: 'platform-node' }, node: 'platform-data-node' },
|
||||
instance: 'cluster-a',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'direct-node', vmid: 100 });
|
||||
});
|
||||
|
||||
it('prefers proxmox.node over proxmox.nodeName and platform sources', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
proxmox: { node: 'proxmox-node', nodeName: 'proxmox-node-name' },
|
||||
platformData: { proxmox: { node: 'platform-node' }, node: 'platform-data-node' },
|
||||
instance: 'cluster-a',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'proxmox-node', vmid: 100 });
|
||||
});
|
||||
|
||||
it('prefers platformData.proxmox.node over platformData.node', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
platformData: { proxmox: { node: 'platform-proxmox-node' }, node: 'platform-data-node' },
|
||||
instance: 'cluster-a',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'platform-proxmox-node', vmid: 100 });
|
||||
});
|
||||
|
||||
it('falls instance back to the resolved node when no instance source is available', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({ node: 'pve', vmid: 100 }),
|
||||
).toEqual({ id: undefined, instance: 'pve', node: 'pve', vmid: 100 });
|
||||
});
|
||||
|
||||
it('ignores non-object proxmox and platformData values', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({
|
||||
proxmox: 'not-an-object',
|
||||
platformData: null,
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
|
||||
it('returns undefined id when resource.id is blank', () => {
|
||||
expect(
|
||||
getGuestOverrideIdentity({ id: ' ', instance: 'cluster-a', node: 'node-1', vmid: 100 }),
|
||||
).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('guestOverrideStorageId', () => {
|
||||
it('returns the trimmed resource id when no identity can be derived', () => {
|
||||
expect(guestOverrideStorageId({ id: 'res-1' })).toBe('res-1');
|
||||
expect(guestOverrideStorageId({ id: ' res-1 ' })).toBe('res-1');
|
||||
});
|
||||
|
||||
it('returns an empty string when no identity and no usable id exist', () => {
|
||||
expect(guestOverrideStorageId({})).toBe('');
|
||||
expect(guestOverrideStorageId({ id: ' ' })).toBe('');
|
||||
expect(guestOverrideStorageId({ id: undefined })).toBe('');
|
||||
});
|
||||
|
||||
it('uses the stable guest:instance:vmid key for cluster guests (instance !== node)', () => {
|
||||
expect(
|
||||
guestOverrideStorageId({ instance: 'cluster-a', node: 'node-1', vmid: 100 }),
|
||||
).toBe('guest:cluster-a:100');
|
||||
});
|
||||
|
||||
it('uses the canonical instance:node:vmid key for standalone guests (instance === node)', () => {
|
||||
expect(guestOverrideStorageId({ instance: 'pve', node: 'pve', vmid: 100 })).toBe(
|
||||
'pve:pve:100',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores resource.id when a guest identity is available', () => {
|
||||
expect(
|
||||
guestOverrideStorageId({
|
||||
id: 'res-1',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toBe('guest:cluster-a:100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guestOverrideIdCandidates', () => {
|
||||
it('returns only the resource id when no identity can be derived', () => {
|
||||
expect(guestOverrideIdCandidates({ id: 'res-1' })).toEqual(['res-1']);
|
||||
expect(guestOverrideIdCandidates({ id: ' res-1 ' })).toEqual(['res-1']);
|
||||
});
|
||||
|
||||
it('returns an empty array when no identity and no usable id exist', () => {
|
||||
expect(guestOverrideIdCandidates({})).toEqual([]);
|
||||
expect(guestOverrideIdCandidates({ id: ' ' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds the full candidate set for cluster guests including legacy keys', () => {
|
||||
expect(
|
||||
guestOverrideIdCandidates({
|
||||
id: 'res-1',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual([
|
||||
'guest:cluster-a:100',
|
||||
'cluster-a:node-1:100',
|
||||
'res-1',
|
||||
'cluster-a-100',
|
||||
'cluster-a-node-1-100',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the legacy cluster key for standalone guests and dedupes stable/canonical', () => {
|
||||
expect(
|
||||
guestOverrideIdCandidates({ id: 'res-1', instance: 'pve', node: 'pve', vmid: 100 }),
|
||||
).toEqual(['pve:pve:100', 'res-1', 'pve-100']);
|
||||
});
|
||||
|
||||
it('omits the id candidate when resource.id is missing or blank', () => {
|
||||
expect(
|
||||
guestOverrideIdCandidates({ instance: 'cluster-a', node: 'node-1', vmid: 100 }),
|
||||
).toEqual([
|
||||
'guest:cluster-a:100',
|
||||
'cluster-a:node-1:100',
|
||||
'cluster-a-100',
|
||||
'cluster-a-node-1-100',
|
||||
]);
|
||||
expect(guestOverrideIdCandidates({ id: ' ', instance: 'pve', node: 'pve', vmid: 100 })).toEqual([
|
||||
'pve:pve:100',
|
||||
'pve-100',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGuestOverrideKey', () => {
|
||||
it.each([
|
||||
['canonical cluster key', 'cluster-a:node-1:100', 'guest:cluster-a:100'],
|
||||
['stable guest key', 'guest:cluster-a:100', 'guest:cluster-a:100'],
|
||||
['canonical standalone key', 'pve:pve:100', 'pve:pve:100'],
|
||||
['whitespace-padded canonical', ' cluster-a:node-1:100 ', 'guest:cluster-a:100'],
|
||||
['whitespace-padded stable', ' guest:cluster-a:100 ', 'guest:cluster-a:100'],
|
||||
['too few parts', 'cluster-a:100', 'cluster-a:100'],
|
||||
['too many parts', 'cluster-a:node-1:100:extra', 'cluster-a:node-1:100:extra'],
|
||||
['zero vmid', 'cluster-a:node-1:0', 'cluster-a:node-1:0'],
|
||||
['negative vmid', 'cluster-a:node-1:-1', 'cluster-a:node-1:-1'],
|
||||
['node with slash', 'cluster-a:node/1:100', 'cluster-a:node/1:100'],
|
||||
['capital-G Guest prefix', 'Guest:cluster-a:100', 'Guest:cluster-a:100'],
|
||||
['empty string', '', ''],
|
||||
['only whitespace', ' ', ''],
|
||||
])('normalizes %s to the expected form', (_label, input, expected) => {
|
||||
expect(normalizeGuestOverrideKey(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('is idempotent for an already-stable cluster key', () => {
|
||||
const stable = normalizeGuestOverrideKey('cluster-a:node-1:100');
|
||||
expect(normalizeGuestOverrideKey(stable)).toBe(stable);
|
||||
});
|
||||
});
|
||||
});
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RawOverrideConfig } from '@/types/alerts';
|
||||
|
||||
import type { Override } from '../types';
|
||||
import {
|
||||
findOverrideForResource,
|
||||
findRawOverrideConfigForResource,
|
||||
getOverridePersistenceIdentity,
|
||||
stripOverrideCandidates,
|
||||
stripRawOverrideCandidates,
|
||||
} from '../guestThresholdOverrideMutationModel';
|
||||
|
||||
const override = (id: string): Override =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
type: 'guest',
|
||||
thresholds: {},
|
||||
}) as Override;
|
||||
|
||||
const makeRawConfig = (trigger: number): RawOverrideConfig =>
|
||||
({ cpu: { trigger, clear: trigger - 5 } }) as RawOverrideConfig;
|
||||
|
||||
describe('guestThresholdOverrideMutationModel', () => {
|
||||
describe('getOverridePersistenceIdentity', () => {
|
||||
it('returns an empty identity for a missing resource', () => {
|
||||
expect(getOverridePersistenceIdentity(undefined)).toEqual({
|
||||
candidateIds: [],
|
||||
storageId: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the exact id for non-guest resources', () => {
|
||||
expect(
|
||||
getOverridePersistenceIdentity({ id: 'agent-1', type: 'agent' }),
|
||||
).toEqual({ candidateIds: ['agent-1'], storageId: 'agent-1' });
|
||||
});
|
||||
|
||||
it('returns empty candidates for a non-guest resource with a blank id', () => {
|
||||
expect(getOverridePersistenceIdentity({ id: '', type: 'agent' })).toEqual({
|
||||
candidateIds: [],
|
||||
storageId: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the full guest candidate set and stable storage id for cluster guests', () => {
|
||||
const identity = getOverridePersistenceIdentity({
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
});
|
||||
|
||||
expect(identity.storageId).toBe('guest:cluster-a:100');
|
||||
expect(identity.candidateIds).toEqual([
|
||||
'guest:cluster-a:100',
|
||||
'cluster-a:node-1:100',
|
||||
'res-1',
|
||||
'cluster-a-100',
|
||||
'cluster-a-node-1-100',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the canonical storage id for standalone guests', () => {
|
||||
const identity = getOverridePersistenceIdentity({
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'pve',
|
||||
node: 'pve',
|
||||
vmid: 100,
|
||||
});
|
||||
|
||||
expect(identity.storageId).toBe('pve:pve:100');
|
||||
expect(identity.candidateIds).toEqual(['pve:pve:100', 'res-1', 'pve-100']);
|
||||
});
|
||||
|
||||
it('falls back to the resource id when no guest identity can be derived', () => {
|
||||
expect(getOverridePersistenceIdentity({ id: 'res-1', type: 'guest' })).toEqual({
|
||||
candidateIds: ['res-1'],
|
||||
storageId: 'res-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a blank-id singleton candidate when no identity and no id exist', () => {
|
||||
expect(getOverridePersistenceIdentity({ id: '', type: 'guest' })).toEqual({
|
||||
candidateIds: [''],
|
||||
storageId: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOverrideForResource', () => {
|
||||
const overrides = [
|
||||
override('guest:cluster-a:100'),
|
||||
override('cluster-a-node-1-100'),
|
||||
override('agent-1'),
|
||||
];
|
||||
|
||||
it('returns undefined for a missing resource', () => {
|
||||
expect(findOverrideForResource(overrides, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches by exact id for non-guest resources', () => {
|
||||
expect(findOverrideForResource(overrides, { id: 'agent-1', type: 'agent' })).toEqual(
|
||||
override('agent-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the first override against any guest candidate id', () => {
|
||||
expect(
|
||||
findOverrideForResource(overrides, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual(override('guest:cluster-a:100'));
|
||||
});
|
||||
|
||||
it('matches a legacy cluster candidate id', () => {
|
||||
const legacyOnly = [override('cluster-a-node-1-100')];
|
||||
expect(
|
||||
findOverrideForResource(legacyOnly, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual(override('cluster-a-node-1-100'));
|
||||
});
|
||||
|
||||
it('returns undefined when no candidate matches', () => {
|
||||
expect(
|
||||
findOverrideForResource(overrides, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'other',
|
||||
node: 'node-9',
|
||||
vmid: 999,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when candidateIds is empty and overrides is non-empty', () => {
|
||||
expect(
|
||||
findOverrideForResource(overrides, { id: '', type: 'agent' }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRawOverrideConfigForResource', () => {
|
||||
const rawConfig: Record<string, RawOverrideConfig> = {
|
||||
'guest:cluster-a:100': { cpu: { trigger: 90, clear: 85 } } as RawOverrideConfig,
|
||||
'cluster-a-node-1-100': { cpu: { trigger: 70, clear: 65 } } as RawOverrideConfig,
|
||||
'agent-1': { cpu: { trigger: 50, clear: 45 } } as RawOverrideConfig,
|
||||
};
|
||||
|
||||
it('returns undefined for a missing resource', () => {
|
||||
expect(findRawOverrideConfigForResource(rawConfig, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches by exact id for non-guest resources', () => {
|
||||
expect(
|
||||
findRawOverrideConfigForResource(rawConfig, { id: 'agent-1', type: 'agent' }),
|
||||
).toEqual({ cpu: { trigger: 50, clear: 45 } });
|
||||
});
|
||||
|
||||
it('returns the first truthy raw config across guest candidate ids in priority order', () => {
|
||||
expect(
|
||||
findRawOverrideConfigForResource(rawConfig, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({ cpu: { trigger: 90, clear: 85 } });
|
||||
});
|
||||
|
||||
it('falls back to a later candidate id when earlier ones are absent', () => {
|
||||
const onlyLegacy: Record<string, RawOverrideConfig> = {
|
||||
'cluster-a-node-1-100': { cpu: { trigger: 70, clear: 65 } } as RawOverrideConfig,
|
||||
};
|
||||
expect(
|
||||
findRawOverrideConfigForResource(onlyLegacy, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({ cpu: { trigger: 70, clear: 65 } });
|
||||
});
|
||||
|
||||
it('returns undefined when no candidate key has a config', () => {
|
||||
expect(
|
||||
findRawOverrideConfigForResource(rawConfig, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'other',
|
||||
node: 'node-9',
|
||||
vmid: 999,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips falsy values and returns the next truthy candidate', () => {
|
||||
const withGap: Record<string, RawOverrideConfig> = {
|
||||
'guest:cluster-a:100': undefined as unknown as RawOverrideConfig,
|
||||
'cluster-a:node-1:100': { cpu: { trigger: 80, clear: 75 } } as RawOverrideConfig,
|
||||
};
|
||||
expect(
|
||||
findRawOverrideConfigForResource(withGap, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
}),
|
||||
).toEqual({ cpu: { trigger: 80, clear: 75 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripOverrideCandidates', () => {
|
||||
const overrides = [
|
||||
override('guest:cluster-a:100'),
|
||||
override('cluster-a:node-1:100'),
|
||||
override('res-1'),
|
||||
override('cluster-a-100'),
|
||||
override('cluster-a-node-1-100'),
|
||||
override('agent-1'),
|
||||
];
|
||||
|
||||
it('returns the same array reference when there are no candidate ids', () => {
|
||||
const result = stripOverrideCandidates(overrides, undefined);
|
||||
expect(result).toBe(overrides);
|
||||
});
|
||||
|
||||
it('returns the same array reference for a non-guest resource with a blank id', () => {
|
||||
const result = stripOverrideCandidates(overrides, { id: '', type: 'agent' });
|
||||
expect(result).toBe(overrides);
|
||||
});
|
||||
|
||||
it('removes only the exact id override for non-guest resources', () => {
|
||||
const result = stripOverrideCandidates(overrides, { id: 'agent-1', type: 'agent' });
|
||||
expect(result.map((o) => o.id)).not.toContain('agent-1');
|
||||
expect(result.map((o) => o.id)).toEqual([
|
||||
'guest:cluster-a:100',
|
||||
'cluster-a:node-1:100',
|
||||
'res-1',
|
||||
'cluster-a-100',
|
||||
'cluster-a-node-1-100',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes every guest candidate override for a cluster guest', () => {
|
||||
const result = stripOverrideCandidates(overrides, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
});
|
||||
expect(result.map((o) => o.id)).toEqual(['agent-1']);
|
||||
});
|
||||
|
||||
it('does not mutate the input overrides array', () => {
|
||||
const snapshot = overrides.map((o) => o.id);
|
||||
stripOverrideCandidates(overrides, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
});
|
||||
expect(overrides.map((o) => o.id)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripRawOverrideCandidates', () => {
|
||||
const base: Record<string, RawOverrideConfig> = {
|
||||
'guest:cluster-a:100': makeRawConfig(90),
|
||||
'cluster-a:node-1:100': makeRawConfig(80),
|
||||
'cluster-a-100': makeRawConfig(70),
|
||||
'cluster-a-node-1-100': makeRawConfig(60),
|
||||
'agent-1': makeRawConfig(50),
|
||||
};
|
||||
|
||||
it('returns a new object with no candidates removed for a missing resource', () => {
|
||||
const result = stripRawOverrideCandidates(base, undefined);
|
||||
expect(result).not.toBe(base);
|
||||
expect(result).toEqual(base);
|
||||
});
|
||||
|
||||
it('removes only the exact id key for non-guest resources', () => {
|
||||
const result = stripRawOverrideCandidates(base, { id: 'agent-1', type: 'agent' });
|
||||
expect(Object.keys(result).sort()).toEqual([
|
||||
'cluster-a-100',
|
||||
'cluster-a-node-1-100',
|
||||
'cluster-a:node-1:100',
|
||||
'guest:cluster-a:100',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes every guest candidate key for a cluster guest', () => {
|
||||
const result = stripRawOverrideCandidates(base, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
});
|
||||
expect(Object.keys(result)).toEqual(['agent-1']);
|
||||
});
|
||||
|
||||
it('does not mutate the input config object', () => {
|
||||
const snapshot = { ...base };
|
||||
stripRawOverrideCandidates(base, {
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
});
|
||||
expect(base).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('is a no-op on candidate keys that are not present in the config', () => {
|
||||
const result = stripRawOverrideCandidates(
|
||||
{ 'agent-1': makeRawConfig(50) },
|
||||
{
|
||||
id: 'res-1',
|
||||
type: 'guest',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 100,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ 'agent-1': makeRawConfig(50) });
|
||||
});
|
||||
});
|
||||
});
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RawOverrideConfig } from '@/types/alerts';
|
||||
|
||||
import type { Override } from '../types';
|
||||
import {
|
||||
stripStateKeys,
|
||||
upsertOverride,
|
||||
withThresholdEntries,
|
||||
} from '../thresholdsOverrideMutationModel';
|
||||
|
||||
const makeOverride = (id: string, thresholds: Override['thresholds'] = {}): Override =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
type: 'guest',
|
||||
thresholds,
|
||||
}) as Override;
|
||||
|
||||
describe('thresholdsOverrideMutationModel', () => {
|
||||
describe('upsertOverride', () => {
|
||||
it('appends a new override when no matching id exists', () => {
|
||||
const existing = [makeOverride('a'), makeOverride('b')];
|
||||
const next = upsertOverride(existing, makeOverride('c'));
|
||||
|
||||
expect(next.map((o) => o.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('replaces an existing override with the same id in place', () => {
|
||||
const existing = [makeOverride('a'), makeOverride('b'), makeOverride('c')];
|
||||
const replacement = makeOverride('b', { cpu: 90 });
|
||||
const next = upsertOverride(existing, replacement);
|
||||
|
||||
expect(next.map((o) => o.id)).toEqual(['a', 'b', 'c']);
|
||||
expect(next[1]).toBe(replacement);
|
||||
});
|
||||
|
||||
it('replaces only the first matching id', () => {
|
||||
const dup = makeOverride('b', { cpu: 1 });
|
||||
const existing = [makeOverride('a'), dup, makeOverride('b', { cpu: 2 })];
|
||||
const replacement = makeOverride('b', { cpu: 90 });
|
||||
const next = upsertOverride(existing, replacement);
|
||||
|
||||
expect(next[1]).toBe(replacement);
|
||||
expect(next[2]).toBe(existing[2]);
|
||||
});
|
||||
|
||||
it('appends to an empty list', () => {
|
||||
const next = upsertOverride([], makeOverride('a'));
|
||||
expect(next.map((o) => o.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const existing = [makeOverride('a')];
|
||||
upsertOverride(existing, makeOverride('b'));
|
||||
expect(existing.map((o) => o.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('preserves the order of unrelated overrides when replacing', () => {
|
||||
const existing = [
|
||||
makeOverride('a'),
|
||||
makeOverride('b'),
|
||||
makeOverride('c'),
|
||||
makeOverride('d'),
|
||||
];
|
||||
const next = upsertOverride(existing, makeOverride('c', { cpu: 90 }));
|
||||
expect(next.map((o) => o.id)).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(next[2].thresholds).toEqual({ cpu: 90 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('withThresholdEntries', () => {
|
||||
it('adds hysteresis entries for each provided threshold', () => {
|
||||
const result = withThresholdEntries({}, { cpu: 90, memory: 80 });
|
||||
expect(result).toEqual({
|
||||
cpu: { trigger: 90, clear: 85 },
|
||||
memory: { trigger: 80, clear: 75 },
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps the clear value to zero when the trigger is below the margin', () => {
|
||||
expect(withThresholdEntries({}, { cpu: 3 })).toEqual({
|
||||
cpu: { trigger: 3, clear: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps the clear value to zero when the trigger is exactly the margin', () => {
|
||||
expect(withThresholdEntries({}, { cpu: 5 })).toEqual({
|
||||
cpu: { trigger: 5, clear: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the clear value at zero for a zero trigger', () => {
|
||||
expect(withThresholdEntries({}, { cpu: 0 })).toEqual({
|
||||
cpu: { trigger: 0, clear: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps the clear value to zero for a negative trigger (max(0, ...))', () => {
|
||||
expect(withThresholdEntries({}, { cpu: -10 })).toEqual({
|
||||
cpu: { trigger: -10, clear: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['undefined', undefined],
|
||||
['null', null],
|
||||
])('skips %s threshold values', (_label, value) => {
|
||||
expect(withThresholdEntries({}, { cpu: value as number | undefined, memory: 80 })).toEqual({
|
||||
memory: { trigger: 80, clear: 75 },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing entries on the raw config and overwrites metrics by name', () => {
|
||||
const base = {
|
||||
cpu: { trigger: 50, clear: 45 },
|
||||
note: 'keep me',
|
||||
} as RawOverrideConfig;
|
||||
const result = withThresholdEntries(base, { cpu: 90, disk: 70 });
|
||||
|
||||
expect(result).toEqual({
|
||||
cpu: { trigger: 90, clear: 85 },
|
||||
disk: { trigger: 70, clear: 65 },
|
||||
note: 'keep me',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the input raw config', () => {
|
||||
const base = { cpu: { trigger: 50, clear: 45 } } as RawOverrideConfig;
|
||||
const snapshot = { ...base };
|
||||
withThresholdEntries(base, { cpu: 90 });
|
||||
expect(base).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('returns an empty config for an empty thresholds map', () => {
|
||||
expect(withThresholdEntries({}, {})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripStateKeys', () => {
|
||||
it('removes disabled, disableConnectivity, and poweredOffSeverity', () => {
|
||||
const input = {
|
||||
cpu: 90,
|
||||
memory: 80,
|
||||
disabled: true,
|
||||
disableConnectivity: true,
|
||||
poweredOffSeverity: 'critical',
|
||||
} as unknown as Record<string, number>;
|
||||
expect(stripStateKeys(input)).toEqual({ cpu: 90, memory: 80 });
|
||||
});
|
||||
|
||||
it('is a no-op when no state keys are present', () => {
|
||||
const input = { cpu: 90, memory: 80 } as Record<string, number>;
|
||||
expect(stripStateKeys(input)).toEqual({ cpu: 90, memory: 80 });
|
||||
});
|
||||
|
||||
it('returns a new object and does not mutate the input', () => {
|
||||
const input = { cpu: 90, disabled: 1 } as unknown as Record<string, number>;
|
||||
const result = stripStateKeys(input);
|
||||
expect(result).not.toBe(input);
|
||||
expect((input as Record<string, unknown>).disabled).toBe(1);
|
||||
});
|
||||
|
||||
it('handles an empty object', () => {
|
||||
expect(stripStateKeys({})).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { LicenseRuntimeCapabilityBlock, LicenseRuntimeIdentity } from '@/api/license';
|
||||
|
||||
import {
|
||||
PATROL_AUTONOMY_FEATURE_KEY,
|
||||
PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON,
|
||||
getPatrolAutonomyAvailabilityPresentation,
|
||||
} from '../patrolAutonomyAvailability';
|
||||
import type { UpgradeDestination } from '@/utils/upgradeNavigation';
|
||||
|
||||
const planDestination: UpgradeDestination = {
|
||||
href: '/plans',
|
||||
external: false,
|
||||
hardNavigation: false,
|
||||
newTab: false,
|
||||
preserveOpener: false,
|
||||
};
|
||||
|
||||
const externalDestination = (href: string): UpgradeDestination => ({
|
||||
href,
|
||||
external: true,
|
||||
hardNavigation: true,
|
||||
newTab: true,
|
||||
preserveOpener: false,
|
||||
});
|
||||
|
||||
const internalDestination = (href: string): UpgradeDestination => ({
|
||||
href,
|
||||
external: false,
|
||||
hardNavigation: false,
|
||||
newTab: false,
|
||||
preserveOpener: false,
|
||||
});
|
||||
|
||||
const runtimeBlock = (
|
||||
overrides: Partial<LicenseRuntimeCapabilityBlock> = {},
|
||||
): LicenseRuntimeCapabilityBlock => ({
|
||||
key: PATROL_AUTONOMY_FEATURE_KEY,
|
||||
reason: PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const runtime = (
|
||||
overrides: Partial<LicenseRuntimeIdentity> = {},
|
||||
): LicenseRuntimeIdentity => ({
|
||||
build: 'community-1',
|
||||
label: 'Community runtime',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('patrolAutonomyAvailability', () => {
|
||||
describe('exported constants', () => {
|
||||
it('exposes the ai_autofix feature key', () => {
|
||||
expect(PATROL_AUTONOMY_FEATURE_KEY).toBe('ai_autofix');
|
||||
});
|
||||
|
||||
it('exposes the paid_runtime_required reason', () => {
|
||||
expect(PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON).toBe('paid_runtime_required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatrolAutonomyAvailabilityPresentation', () => {
|
||||
it('is available when autoFix is not locked, ignoring every other field', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: false,
|
||||
runtimeCapabilityBlock: runtimeBlock(),
|
||||
runtime: runtime(),
|
||||
commercialSurfacesHidden: true,
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'available',
|
||||
locked: false,
|
||||
title: 'Patrol mode available',
|
||||
body: 'Choose the mode for this install.',
|
||||
});
|
||||
});
|
||||
|
||||
it('is available when autoFixLocked is falsy', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: undefined as unknown as boolean,
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
expect(result.kind).toBe('available');
|
||||
expect(result.locked).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'runtime_locked beats commercialSurfacesHidden',
|
||||
{
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: runtimeBlock(),
|
||||
runtime: runtime(),
|
||||
commercialSurfacesHidden: true,
|
||||
upgradePromptsHidden: true,
|
||||
planUpgradeDestination: planDestination,
|
||||
},
|
||||
'runtime_locked',
|
||||
],
|
||||
[
|
||||
'plan_locked when commercialSurfacesHidden and no runtime block',
|
||||
{
|
||||
autoFixLocked: true,
|
||||
commercialSurfacesHidden: true,
|
||||
planUpgradeDestination: planDestination,
|
||||
},
|
||||
'plan_locked',
|
||||
],
|
||||
[
|
||||
'plan_locked by default when locked but no block and surfaces visible',
|
||||
{
|
||||
autoFixLocked: true,
|
||||
planUpgradeDestination: planDestination,
|
||||
},
|
||||
'plan_locked',
|
||||
],
|
||||
])('resolves kind precedence: %s', (_label, input, expectedKind) => {
|
||||
expect(getPatrolAutonomyAvailabilityPresentation(input).kind).toBe(expectedKind);
|
||||
});
|
||||
|
||||
describe('runtime_locked presentation', () => {
|
||||
it('builds the runtime-locked surface with the runtime label in the body', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: runtimeBlock({ action_url: 'https://pro.example/downloads' }),
|
||||
runtime: runtime({ label: 'Community runtime' }),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'runtime_locked',
|
||||
locked: true,
|
||||
title: 'Pulse Pro runtime required',
|
||||
body: 'This install is running Community runtime. Install the Pulse Pro runtime to use Patrol modes.',
|
||||
actionLabel: 'Open Pro downloads',
|
||||
destination: externalDestination('https://pro.example/downloads'),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the runtime download_url when the block has no action_url', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: runtimeBlock(),
|
||||
runtime: runtime({ download_url: '/downloads/pro' }),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.actionLabel).toBe('Open Pro downloads');
|
||||
expect(result.destination).toEqual(internalDestination('/downloads/pro'));
|
||||
});
|
||||
|
||||
it('prefers the block action_url over the runtime download_url', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: runtimeBlock({ action_url: 'https://block.example/dl' }),
|
||||
runtime: runtime({ download_url: 'https://runtime.example/dl' }),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.destination).toEqual(externalDestination('https://block.example/dl'));
|
||||
});
|
||||
|
||||
it('trims whitespace on the action_url before resolving', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: runtimeBlock({ action_url: ' https://pro.example/downloads ' }),
|
||||
runtime: runtime(),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.destination).toEqual(externalDestination('https://pro.example/downloads'));
|
||||
});
|
||||
|
||||
it('falls back to the plan upgrade destination when no runtime download source exists', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: runtimeBlock(),
|
||||
runtime: runtime(),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.destination).toBe(planDestination);
|
||||
});
|
||||
|
||||
it('falls back to the plan destination when action_url and download_url are blank', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: runtimeBlock({ action_url: ' ' }),
|
||||
runtime: runtime({ download_url: ' ' }),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.destination).toBe(planDestination);
|
||||
});
|
||||
|
||||
it('omits the action label and destination when upgrade prompts are hidden', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
upgradePromptsHidden: true,
|
||||
runtimeCapabilityBlock: runtimeBlock({ action_url: 'https://pro.example/downloads' }),
|
||||
runtime: runtime(),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'runtime_locked',
|
||||
locked: true,
|
||||
title: 'Pulse Pro runtime required',
|
||||
body: 'This install is running Community runtime. Install the Pulse Pro runtime to use Patrol modes.',
|
||||
});
|
||||
expect(result).not.toHaveProperty('actionLabel');
|
||||
expect(result).not.toHaveProperty('destination');
|
||||
});
|
||||
|
||||
it('falls back to the "this runtime" label when the runtime label is blank', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
upgradePromptsHidden: true,
|
||||
runtimeCapabilityBlock: runtimeBlock(),
|
||||
runtime: runtime({ label: ' ' }),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.body).toBe(
|
||||
'This install is running this runtime. Install the Pulse Pro runtime to use Patrol modes.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the "this runtime" label when runtime is undefined', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
upgradePromptsHidden: true,
|
||||
runtimeCapabilityBlock: runtimeBlock(),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.body).toBe(
|
||||
'This install is running this runtime. Install the Pulse Pro runtime to use Patrol modes.',
|
||||
);
|
||||
});
|
||||
|
||||
it('trims the runtime label before interpolating it', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
upgradePromptsHidden: true,
|
||||
runtimeCapabilityBlock: runtimeBlock(),
|
||||
runtime: runtime({ label: ' Pulse Pro ' }),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.body).toBe(
|
||||
'This install is running Pulse Pro. Install the Pulse Pro runtime to use Patrol modes.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat a non-matching capability reason as runtime locked', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: runtimeBlock({ reason: 'some_other_reason' }),
|
||||
runtime: runtime(),
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('plan_locked');
|
||||
expect(result.actionLabel).toBe('Plans & Billing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan_locked presentation', () => {
|
||||
it('shows the Plans & Billing action when surfaces are visible', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'plan_locked',
|
||||
locked: true,
|
||||
title: 'Watch only',
|
||||
body: 'This install watches infrastructure and shows issues.',
|
||||
actionLabel: 'Plans & Billing',
|
||||
destination: planDestination,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the action when upgrade prompts are hidden', () => {
|
||||
const result = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
upgradePromptsHidden: true,
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'plan_locked',
|
||||
locked: true,
|
||||
title: 'Watch only',
|
||||
body: 'This install watches infrastructure and shows issues.',
|
||||
});
|
||||
expect(result).not.toHaveProperty('actionLabel');
|
||||
expect(result).not.toHaveProperty('destination');
|
||||
});
|
||||
|
||||
it('omits the action when commercial surfaces are hidden, regardless of upgrade prompt visibility', () => {
|
||||
const withPromptsVisible = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
commercialSurfacesHidden: true,
|
||||
planUpgradeDestination: planDestination,
|
||||
});
|
||||
|
||||
expect(withPromptsVisible).toEqual({
|
||||
kind: 'plan_locked',
|
||||
locked: true,
|
||||
title: 'Watch only',
|
||||
body: 'This install watches infrastructure and shows issues.',
|
||||
});
|
||||
expect(withPromptsVisible).not.toHaveProperty('actionLabel');
|
||||
expect(withPromptsVisible).not.toHaveProperty('destination');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user