Track per-device traffic totals and recover inventory state
This commit is contained in:
@@ -7,6 +7,8 @@ import { parseNeighborSnapshot, readNeighborSnapshot } from '../../src/server/ad
|
||||
import {
|
||||
createDeviceInventoryService,
|
||||
createVendorLookup,
|
||||
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
migrateDeviceInventoryState,
|
||||
} from '../../src/server/services/deviceInventoryService.js';
|
||||
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
||||
|
||||
@@ -100,3 +102,244 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
|
||||
assert.deepEqual(failed.observations, []);
|
||||
assert.match(failed.error, /not available/);
|
||||
});
|
||||
|
||||
test('device traffic totals persist exact deltas across polls and process epochs', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-traffic-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
});
|
||||
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||
const mac = '00:11:22:33:44:55';
|
||||
const neighbor = {
|
||||
observedAt,
|
||||
observations: [{
|
||||
ip: '192.168.50.7',
|
||||
mac,
|
||||
interface: 'eth0',
|
||||
observedAt,
|
||||
active: true,
|
||||
}],
|
||||
error: null,
|
||||
};
|
||||
let traffic = {
|
||||
epoch: 'epoch-a',
|
||||
generation: 'rules-a',
|
||||
observedAt,
|
||||
source: { error: null },
|
||||
devices: [{
|
||||
ip: '192.168.50.7',
|
||||
mac,
|
||||
interface: 'eth0',
|
||||
uploadBytes: '9007199254740993',
|
||||
downloadBytes: '100',
|
||||
}],
|
||||
};
|
||||
let trafficError = null;
|
||||
const createService = () => createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => neighbor,
|
||||
observeTraffic: () => {
|
||||
if (trafficError) throw trafficError;
|
||||
return traffic;
|
||||
},
|
||||
});
|
||||
let service = createService();
|
||||
|
||||
let snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '100');
|
||||
assert.equal(snapshot.devices[0].trafficObservedAt, observedAt);
|
||||
assert.deepEqual(snapshot.source.traffic, { lastObservedAt: observedAt, error: null });
|
||||
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '100');
|
||||
|
||||
traffic = {
|
||||
...traffic,
|
||||
devices: [{ ...traffic.devices[0], uploadBytes: '9007199254740995', downloadBytes: '150' }],
|
||||
};
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '150');
|
||||
|
||||
service = createService();
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '150');
|
||||
|
||||
traffic = {
|
||||
...traffic,
|
||||
epoch: 'epoch-b',
|
||||
generation: 'rules-b',
|
||||
devices: [{ ...traffic.devices[0], uploadBytes: '10', downloadBytes: '20' }],
|
||||
};
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '170');
|
||||
|
||||
traffic = {
|
||||
...traffic,
|
||||
devices: [{ ...traffic.devices[0], uploadBytes: '9', downloadBytes: '20' }],
|
||||
};
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||
assert.match(snapshot.source.traffic.error, /уменьшился/);
|
||||
|
||||
trafficError = new Error('traffic unavailable');
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||
assert.equal(snapshot.source.traffic.lastObservedAt, observedAt);
|
||||
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
||||
});
|
||||
|
||||
test('device inventory v1 migration creates a versioned backup', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'devices.json');
|
||||
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, revision: 4, devices: [] }));
|
||||
const store = createJsonStore({
|
||||
filePath,
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
|
||||
});
|
||||
|
||||
const migrated = store.read();
|
||||
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
||||
assert.deepEqual(migrated.traffic.baselinesByMac, {});
|
||||
assert.match(store.migration?.backupPath || '', /\.backup-v1-/);
|
||||
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||
});
|
||||
|
||||
test('device inventory backs up malformed v2 traffic and re-baselines without double counting', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-traffic-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'devices.json');
|
||||
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||
const macs = [
|
||||
'00:11:22:33:44:55',
|
||||
'00:11:22:33:44:66',
|
||||
'00:11:22:33:44:77',
|
||||
'00:11:22:33:44:88',
|
||||
'00:11:22:33:44:99',
|
||||
];
|
||||
const [firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac] = macs;
|
||||
const devices = macs.map((mac, index) => ({
|
||||
id: `device-${index}`,
|
||||
alias: '',
|
||||
pinned: false,
|
||||
hostname: null,
|
||||
manufacturer: null,
|
||||
mac,
|
||||
ip: `192.168.50.${index + 7}`,
|
||||
interface: 'eth0',
|
||||
firstSeenAt: mac === expiredMac ? '2026-06-01T12:00:00.000Z' : observedAt,
|
||||
lastSeenAt: mac === expiredMac ? '2026-06-01T12:00:00.000Z' : observedAt,
|
||||
source: 'neighbor',
|
||||
confidence: 'high',
|
||||
}));
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
revision: 4,
|
||||
lastObservedAt: observedAt,
|
||||
lastError: null,
|
||||
devices,
|
||||
traffic: {
|
||||
epoch: 'epoch-a',
|
||||
generation: 'rules-a',
|
||||
lastObservedAt: observedAt,
|
||||
lastError: null,
|
||||
baselinesByMac: {
|
||||
[firstMac]: { epoch: 'epoch-a', uploadBytes: 'broken', downloadBytes: '100' },
|
||||
[secondMac]: { epoch: 'epoch-a', uploadBytes: '50', downloadBytes: '60' },
|
||||
[missingTotalMac]: { epoch: 'epoch-a', uploadBytes: '90', downloadBytes: '100' },
|
||||
[expiredMac]: { epoch: 'epoch-a', uploadBytes: '110', downloadBytes: '120' },
|
||||
},
|
||||
totalsByMac: {
|
||||
[firstMac]: { uploadBytes: '500', downloadBytes: '600', observedAt },
|
||||
[secondMac]: { uploadBytes: 'broken', downloadBytes: '700', observedAt },
|
||||
[missingBaselineMac]: { uploadBytes: '800', downloadBytes: '900', observedAt },
|
||||
[expiredMac]: { uploadBytes: 'broken', downloadBytes: '1000', observedAt },
|
||||
},
|
||||
},
|
||||
}));
|
||||
const store = createJsonStore({
|
||||
filePath,
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
backupWhen: () => true,
|
||||
});
|
||||
|
||||
const migrated = store.read();
|
||||
assert.match(store.migration?.backupPath || '', /\.backup-v2-/);
|
||||
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||
assert.deepEqual(migrated.traffic.totalsByMac[firstMac], {
|
||||
uploadBytes: '500',
|
||||
downloadBytes: '600',
|
||||
observedAt,
|
||||
});
|
||||
assert.equal(migrated.traffic.totalsByMac[secondMac], undefined);
|
||||
assert.deepEqual(
|
||||
new Set(migrated.traffic.rebaselineMacs),
|
||||
new Set([firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac]),
|
||||
);
|
||||
|
||||
let counters = [
|
||||
{ mac: firstMac, uploadBytes: '200', downloadBytes: '300' },
|
||||
{ mac: secondMac, uploadBytes: '70', downloadBytes: '80' },
|
||||
{ mac: missingBaselineMac, uploadBytes: '110', downloadBytes: '120' },
|
||||
{ mac: missingTotalMac, uploadBytes: '130', downloadBytes: '140' },
|
||||
];
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt,
|
||||
error: null,
|
||||
observations: devices.filter(({ mac }) => mac !== expiredMac)
|
||||
.map(({ ip, mac, interface: deviceInterface }) => ({
|
||||
ip,
|
||||
mac,
|
||||
interface: deviceInterface,
|
||||
observedAt,
|
||||
active: true,
|
||||
})),
|
||||
}),
|
||||
observeTraffic: () => ({
|
||||
epoch: 'epoch-a',
|
||||
generation: 'rules-a',
|
||||
observedAt,
|
||||
source: { error: null },
|
||||
devices: counters,
|
||||
}),
|
||||
});
|
||||
|
||||
let snapshot = await service.refresh();
|
||||
let byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
|
||||
assert.equal(byMac.get(firstMac).uploadBytes, '500');
|
||||
assert.equal(byMac.get(secondMac).uploadBytes, '0');
|
||||
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '800');
|
||||
assert.equal(byMac.get(missingTotalMac).uploadBytes, '0');
|
||||
assert.equal(byMac.has(expiredMac), false);
|
||||
assert.deepEqual(store.read().traffic.rebaselineMacs, []);
|
||||
assert.equal(snapshot.source.traffic.error, null);
|
||||
counters = [
|
||||
{ mac: firstMac, uploadBytes: '250', downloadBytes: '330' },
|
||||
{ mac: secondMac, uploadBytes: '75', downloadBytes: '90' },
|
||||
{ mac: missingBaselineMac, uploadBytes: '115', downloadBytes: '125' },
|
||||
{ mac: missingTotalMac, uploadBytes: '150', downloadBytes: '160' },
|
||||
];
|
||||
snapshot = await service.refresh();
|
||||
byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
|
||||
assert.equal(byMac.get(firstMac).uploadBytes, '550');
|
||||
assert.equal(byMac.get(firstMac).downloadBytes, '630');
|
||||
assert.equal(byMac.get(secondMac).uploadBytes, '5');
|
||||
assert.equal(byMac.get(secondMac).downloadBytes, '10');
|
||||
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '805');
|
||||
assert.equal(byMac.get(missingBaselineMac).downloadBytes, '905');
|
||||
assert.equal(byMac.get(missingTotalMac).uploadBytes, '20');
|
||||
assert.equal(byMac.get(missingTotalMac).downloadBytes, '20');
|
||||
});
|
||||
|
||||
@@ -123,6 +123,7 @@ test('traffic service preserves active rules and snapshot when replacement fails
|
||||
});
|
||||
|
||||
const first = await service.refresh();
|
||||
assert.equal(first.epoch, 'boot');
|
||||
assert.equal(first.generation, 'rules-a');
|
||||
assert.deepEqual(first.devices, [{
|
||||
ip: '192.168.50.7',
|
||||
@@ -175,3 +176,94 @@ test('traffic service preserves active rules and snapshot when replacement fails
|
||||
assert.deepEqual(timedOut.devices, first.devices);
|
||||
assert.ok(calls.every(([, , options]) => options.timeout === 2_000));
|
||||
});
|
||||
|
||||
test('traffic service finalizes a detached slot once and keeps epoch totals monotonic', async () => {
|
||||
const firstObservation = observation('192.168.50.7', '00:11:22:33:44:55');
|
||||
const secondObservation = observation('192.168.50.8', '00:11:22:33:44:66');
|
||||
const firstDevice = selectTrafficDevices([firstObservation])[0];
|
||||
const secondDevice = selectTrafficDevices([secondObservation])[0];
|
||||
let observed = {
|
||||
observedAt: '2026-08-07T12:00:00.000Z',
|
||||
observations: [firstObservation],
|
||||
error: null,
|
||||
};
|
||||
const values = {
|
||||
A: { upload: '100', download: '200' },
|
||||
B: { upload: '5', download: '7' },
|
||||
};
|
||||
const keys = { A: firstDevice.key, B: secondDevice.key };
|
||||
let failNextCounterRead = false;
|
||||
const run = (command, args) => {
|
||||
if (command !== 'iptables-save') return { status: 0, stdout: '', stderr: '' };
|
||||
if (failNextCounterRead) {
|
||||
failNextCounterRead = false;
|
||||
return { status: null, stdout: '', stderr: '', error: new Error('retired slot read failed') };
|
||||
}
|
||||
const direction = args.includes('raw') ? 'upload' : 'download';
|
||||
const tableChain = args.includes('raw') ? uploadChain : downloadChain;
|
||||
return {
|
||||
status: 0,
|
||||
stdout: ['A', 'B'].map((slot) => (
|
||||
`[1:${values[slot][direction]}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:${direction}" -j RETURN`
|
||||
)).join('\n'),
|
||||
stderr: '',
|
||||
};
|
||||
};
|
||||
const generations = ['epoch-1', 'rules-a', 'rules-b'];
|
||||
const service = createDeviceTrafficService({
|
||||
observe: async () => observed,
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
bypassCidrs: [],
|
||||
run,
|
||||
nextGeneration: () => generations.shift(),
|
||||
});
|
||||
|
||||
const first = await service.refresh();
|
||||
assert.equal(first.epoch, 'epoch-1');
|
||||
assert.equal(first.generation, 'rules-a');
|
||||
assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes,
|
||||
})), [{
|
||||
mac: firstObservation.mac,
|
||||
uploadBytes: '100',
|
||||
downloadBytes: '200',
|
||||
}]);
|
||||
|
||||
values.A = { upload: '130', download: '240' };
|
||||
observed = {
|
||||
observedAt: '2026-08-07T12:01:00.000Z',
|
||||
observations: [secondObservation],
|
||||
error: null,
|
||||
};
|
||||
failNextCounterRead = true;
|
||||
const pending = await service.refresh();
|
||||
assert.equal(pending.epoch, 'epoch-1');
|
||||
assert.equal(pending.generation, 'rules-b');
|
||||
assert.match(pending.source.error, /retired slot read failed/);
|
||||
assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes,
|
||||
})), [
|
||||
{ mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' },
|
||||
]);
|
||||
|
||||
const finalized = await service.refresh();
|
||||
assert.equal(finalized.generation, 'rules-b');
|
||||
assert.equal(finalized.source.error, null);
|
||||
assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes,
|
||||
})), [
|
||||
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' },
|
||||
]);
|
||||
|
||||
values.B = { upload: '15', download: '17' };
|
||||
const polled = await service.refresh();
|
||||
assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes,
|
||||
})), [
|
||||
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,11 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { formatLastSeen } from '../../src/web/utils/format.js';
|
||||
import {
|
||||
formatByteString,
|
||||
formatLastSeen,
|
||||
sortDevicesByTraffic,
|
||||
} from '../../src/web/utils/format.js';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||
@@ -17,6 +21,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /api\.devices\.list\(\)/);
|
||||
assert.match(panel, /api\.devices\.refresh\(\)/);
|
||||
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
||||
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
|
||||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||||
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||||
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||||
@@ -27,6 +32,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /client-device-addresses/);
|
||||
assert.doesNotMatch(panel, /device\.interface/);
|
||||
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
||||
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
|
||||
assert.match(panel, /Трафик временно не обновляется/);
|
||||
assert.match(panel, /Получено \$\{download\}, отдано \$\{upload\}/);
|
||||
assert.match(panel, /client-device-traffic/);
|
||||
assert.match(panel, /client-drawer client-instructions client-devices/);
|
||||
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
||||
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
||||
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
||||
@@ -36,6 +46,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/);
|
||||
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-text-morph-value/);
|
||||
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
||||
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||||
});
|
||||
|
||||
test('device last-seen copy is compact with precise accessible and relative forms', () => {
|
||||
@@ -50,3 +62,18 @@ test('device last-seen copy is compact with precise accessible and relative form
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => {
|
||||
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
|
||||
assert.equal(formatByteString('1536'), '1,5 КБ');
|
||||
assert.equal(formatByteString('invalid'), '0 Б');
|
||||
|
||||
const devices = [
|
||||
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0' },
|
||||
{ id: 'b', uploadBytes: '9007199254740992', downloadBytes: '2' },
|
||||
{ id: 'c', uploadBytes: '10', downloadBytes: '10' },
|
||||
{ id: 'd', uploadBytes: '15', downloadBytes: '5' },
|
||||
];
|
||||
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['b', 'a', 'c', 'd']);
|
||||
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['c', 'd', 'a', 'b']);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
|
||||
|
||||
test('secondary menus share one right rail and both drawers open from the right', () => {
|
||||
const disabledRulesLabel = rule('.client-local-rules-toggle:disabled span');
|
||||
const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]);
|
||||
|
||||
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
|
||||
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
|
||||
@@ -92,10 +93,18 @@ test('secondary menus share one right rail and both drawers open from the right'
|
||||
assert.match(disabledRulesLabel, /opacity:\s*0/);
|
||||
assert.match(disabledRulesLabel, /filter:\s*blur\(5px\)/);
|
||||
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
|
||||
assert.match(rule('.client-instructions'), /inset:\s*0 0 0 auto/);
|
||||
assert.match(rule('.client-instructions'), /transform:\s*translateX\(104%\)/);
|
||||
assert.match(rule('.client-local-rules'), /inset:\s*0 0 0 auto/);
|
||||
assert.match(rule('.client-local-rules'), /transform:\s*translateX\(104%\)/);
|
||||
assert.match(rule('.client-drawer'), /inset:\s*0 0 0 auto/);
|
||||
assert.match(rule('.client-drawer'), /transform:\s*translateX\(104%\)/);
|
||||
assert.match(rule('.client-drawer'), /z-index:\s*50/);
|
||||
assert.match(rule('.client-drawer'), /box-shadow:/);
|
||||
assert.deepEqual(
|
||||
['.client-confirmation-popup', '.client-secondary-menu', '.client-drawer', '.harbor-versions'].map(zIndex),
|
||||
[100, 60, 50, 40],
|
||||
);
|
||||
assert.match(rule('.client-instructions'), /width:\s*min\(470px, 100vw\)/);
|
||||
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
|
||||
assert.match(component, /className={`client-drawer client-instructions/);
|
||||
assert.match(component, /className={`client-drawer client-local-rules/);
|
||||
});
|
||||
|
||||
test('duration and Gateway access keep stable geometry without tabs', () => {
|
||||
|
||||
Reference in New Issue
Block a user