mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-04-19 08:36:14 +00:00
Refactor current state report to patch v3 state
This change makes the `api-binder/report` module more agnostic to internal device state implementation details, moving necessary healthchecks and data filtering to getCurrentForReport in device-state. This also adds generic functions to perform comparison between current state reports.
This commit is contained in:
parent
25e9ab4786
commit
381abeadb9
@ -1,5 +1,5 @@
|
||||
import * as _ from 'lodash';
|
||||
import * as url from 'url';
|
||||
import * as _ from 'lodash';
|
||||
import { CoreOptions } from 'request';
|
||||
|
||||
import * as constants from '../lib/constants';
|
||||
@ -7,79 +7,41 @@ import { withBackoff, OnFailureInfo } from '../lib/backoff';
|
||||
import { log } from '../lib/supervisor-console';
|
||||
import { InternalInconsistencyError, StatusError } from '../lib/errors';
|
||||
import { getRequestInstance } from '../lib/request';
|
||||
import * as sysInfo from '../lib/system-info';
|
||||
|
||||
import { DeviceLegacyState } from '../types';
|
||||
import { DeviceState } from '../types';
|
||||
import * as config from '../config';
|
||||
import { SchemaTypeKey, SchemaReturn } from '../config/schema-type';
|
||||
import * as eventTracker from '../event-tracker';
|
||||
import * as deviceState from '../device-state';
|
||||
|
||||
const INTERNAL_STATE_KEYS = [
|
||||
'update_pending',
|
||||
'update_downloaded',
|
||||
'update_failed',
|
||||
];
|
||||
import { shallowDiff, prune, empty } from '../lib/json';
|
||||
|
||||
export let stateReportErrors = 0;
|
||||
const lastReportedState: DeviceLegacyState = {
|
||||
local: {},
|
||||
dependent: {},
|
||||
};
|
||||
let lastReport: DeviceState = {};
|
||||
let reportPending = false;
|
||||
export let stateReportErrors = 0;
|
||||
|
||||
type CurrentStateReportConf = {
|
||||
type StateReportOpts = {
|
||||
[key in keyof Pick<
|
||||
config.ConfigMap<SchemaTypeKey>,
|
||||
| 'uuid'
|
||||
| 'apiEndpoint'
|
||||
| 'apiTimeout'
|
||||
| 'deviceApiKey'
|
||||
| 'deviceId'
|
||||
| 'localMode'
|
||||
| 'appUpdatePollInterval'
|
||||
| 'hardwareMetrics'
|
||||
'apiEndpoint' | 'apiTimeout' | 'deviceApiKey' | 'appUpdatePollInterval'
|
||||
>]: SchemaReturn<key>;
|
||||
};
|
||||
|
||||
type StateReport = {
|
||||
stateDiff: DeviceLegacyState;
|
||||
conf: Omit<CurrentStateReportConf, 'deviceId' | 'hardwareMetrics'>;
|
||||
};
|
||||
type StateReport = { body: Partial<DeviceState>; opts: StateReportOpts };
|
||||
|
||||
/**
|
||||
* Returns an object that contains only status fields relevant for the local mode.
|
||||
* It basically removes information about applications state.
|
||||
*/
|
||||
const stripDeviceStateInLocalMode = (
|
||||
state: DeviceLegacyState,
|
||||
): DeviceLegacyState => {
|
||||
return {
|
||||
local: _.cloneDeep(
|
||||
_.omit(state.local, 'apps', 'is_on__commit', 'logs_channel'),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
async function report({ stateDiff, conf }: StateReport): Promise<boolean> {
|
||||
let body = stateDiff;
|
||||
const { apiEndpoint, apiTimeout, deviceApiKey, localMode, uuid } = conf;
|
||||
if (localMode) {
|
||||
body = stripDeviceStateInLocalMode(stateDiff);
|
||||
}
|
||||
|
||||
if (_.isEmpty(body.local)) {
|
||||
// Nothing to send.
|
||||
async function report({ body, opts }: StateReport) {
|
||||
const { apiEndpoint, apiTimeout, deviceApiKey } = opts;
|
||||
if (empty(body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conf.uuid == null || conf.apiEndpoint == null) {
|
||||
if (!apiEndpoint) {
|
||||
throw new InternalInconsistencyError(
|
||||
'No uuid or apiEndpoint provided to CurrentState.report',
|
||||
'No apiEndpoint available for patching current state',
|
||||
);
|
||||
}
|
||||
|
||||
const endpoint = url.resolve(apiEndpoint, `/device/v2/${uuid}/state`);
|
||||
const endpoint = url.resolve(apiEndpoint, `/device/v3/state`);
|
||||
const request = await getRequestInstance();
|
||||
|
||||
const params: CoreOptions = {
|
||||
@ -101,68 +63,36 @@ async function report({ stateDiff, conf }: StateReport): Promise<boolean> {
|
||||
headers['retry-after'] ? parseInt(headers['retry-after'], 10) : undefined,
|
||||
);
|
||||
}
|
||||
// State was reported
|
||||
return true;
|
||||
}
|
||||
|
||||
function newStateDiff(stateForReport: DeviceLegacyState): DeviceLegacyState {
|
||||
const lastReportedLocal = lastReportedState.local;
|
||||
const lastReportedDependent = lastReportedState.dependent;
|
||||
if (lastReportedLocal == null || lastReportedDependent == null) {
|
||||
throw new InternalInconsistencyError(
|
||||
`No local or dependent component of lastReportedLocal in CurrentState.getStateDiff: ${JSON.stringify(
|
||||
lastReportedState,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const diff = {
|
||||
local: _.omitBy(
|
||||
stateForReport.local,
|
||||
(val, key: keyof NonNullable<DeviceLegacyState['local']>) =>
|
||||
INTERNAL_STATE_KEYS.includes(key) ||
|
||||
_.isEqual(lastReportedLocal[key], val) ||
|
||||
!sysInfo.isSignificantChange(
|
||||
key,
|
||||
lastReportedLocal[key] as number,
|
||||
val as number,
|
||||
),
|
||||
),
|
||||
dependent: _.omitBy(
|
||||
stateForReport.dependent,
|
||||
(val, key: keyof DeviceLegacyState['dependent']) =>
|
||||
INTERNAL_STATE_KEYS.includes(key) ||
|
||||
_.isEqual(lastReportedDependent[key], val),
|
||||
),
|
||||
};
|
||||
|
||||
return _.omitBy(diff, _.isEmpty);
|
||||
}
|
||||
|
||||
async function reportCurrentState(conf: CurrentStateReportConf) {
|
||||
async function reportCurrentState(opts: StateReportOpts) {
|
||||
// Ensure no other report starts
|
||||
reportPending = true;
|
||||
// Wrap the report with fetching of state so report always has the latest state diff
|
||||
const getStateAndReport = async () => {
|
||||
// Get state to report
|
||||
const stateToReport = await generateStateForReport();
|
||||
// Get diff from last reported state
|
||||
const stateDiff = newStateDiff(stateToReport);
|
||||
const currentState = await deviceState.getCurrentForReport(lastReport);
|
||||
|
||||
// Depth 2 is the apps level
|
||||
const stateDiff = prune(shallowDiff(lastReport, currentState, 2));
|
||||
|
||||
// Report diff
|
||||
if (await report({ stateDiff, conf })) {
|
||||
// Update lastReportedState
|
||||
_.assign(lastReportedState.local, stateDiff.local);
|
||||
_.assign(lastReportedState.dependent, stateDiff.dependent);
|
||||
if (await report({ body: stateDiff, opts })) {
|
||||
// Update lastReportedState if the report succeeds
|
||||
lastReport = currentState;
|
||||
// Log that we successfully reported the current state
|
||||
log.info('Reported current state to the cloud');
|
||||
}
|
||||
};
|
||||
|
||||
// Create a report that will backoff on errors
|
||||
const reportWithBackoff = withBackoff(getStateAndReport, {
|
||||
maxDelay: conf.appUpdatePollInterval,
|
||||
maxDelay: opts.appUpdatePollInterval,
|
||||
minDelay: 15000,
|
||||
onFailure: handleRetry,
|
||||
});
|
||||
|
||||
// Run in try block to avoid throwing any exceptions
|
||||
try {
|
||||
await reportWithBackoff();
|
||||
@ -196,49 +126,14 @@ function handleRetry(retryInfo: OnFailureInfo) {
|
||||
);
|
||||
}
|
||||
|
||||
async function generateStateForReport() {
|
||||
const { hardwareMetrics } = await config.getMany(['hardwareMetrics']);
|
||||
|
||||
const currentDeviceState = await deviceState.getLegacyState();
|
||||
|
||||
// If hardwareMetrics is false, send null patch for system metrics to cloud API
|
||||
const info = {
|
||||
...(hardwareMetrics
|
||||
? await sysInfo.getSystemMetrics()
|
||||
: {
|
||||
cpu_usage: null,
|
||||
memory_usage: null,
|
||||
memory_total: null,
|
||||
storage_usage: null,
|
||||
storage_total: null,
|
||||
storage_block_device: null,
|
||||
cpu_temp: null,
|
||||
cpu_id: null,
|
||||
}),
|
||||
...(await sysInfo.getSystemChecks()),
|
||||
};
|
||||
|
||||
return {
|
||||
local: {
|
||||
...currentDeviceState.local,
|
||||
...info,
|
||||
},
|
||||
dependent: currentDeviceState.dependent,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startReporting() {
|
||||
// Get configs needed to make a report
|
||||
const reportConfigs = (await config.getMany([
|
||||
'uuid',
|
||||
'apiEndpoint',
|
||||
'apiTimeout',
|
||||
'deviceApiKey',
|
||||
'deviceId',
|
||||
'localMode',
|
||||
'appUpdatePollInterval',
|
||||
'hardwareMetrics',
|
||||
])) as CurrentStateReportConf;
|
||||
])) as StateReportOpts;
|
||||
// Throttle reportCurrentState so we don't query device or hit API excessively
|
||||
const throttledReport = _.throttle(
|
||||
reportCurrentState,
|
||||
|
@ -37,14 +37,15 @@ import * as deviceConfig from './device-config';
|
||||
import { ConfigStep } from './device-config';
|
||||
import { log } from './lib/supervisor-console';
|
||||
import {
|
||||
DeviceLegacyReport,
|
||||
DeviceLegacyState,
|
||||
InstancedDeviceState,
|
||||
TargetState,
|
||||
DeviceState,
|
||||
DeviceReport,
|
||||
} from './types';
|
||||
import * as dbFormat from './device-state/db-format';
|
||||
import * as apiKeys from './lib/api-keys';
|
||||
import * as sysInfo from './lib/system-info';
|
||||
|
||||
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
|
||||
|
||||
@ -248,7 +249,7 @@ type DeviceStateStep<T extends PossibleStepTargets> =
|
||||
| CompositionStepT<T extends CompositionStepAction ? T : never>
|
||||
| ConfigStep;
|
||||
|
||||
let currentVolatile: DeviceLegacyReport = {};
|
||||
let currentVolatile: DeviceReport = {};
|
||||
const writeLock = updateLock.writeLock;
|
||||
const readLock = updateLock.readLock;
|
||||
let maxPollTime: number;
|
||||
@ -355,7 +356,7 @@ export async function initNetworkChecks({
|
||||
});
|
||||
log.debug('Starting periodic check for IP addresses');
|
||||
|
||||
await network.startIPAddressUpdate()(async (addresses) => {
|
||||
network.startIPAddressUpdate()(async (addresses) => {
|
||||
const macAddress = await config.get('macAddress');
|
||||
reportCurrentState({
|
||||
ip_address: addresses.join(' '),
|
||||
@ -570,21 +571,75 @@ export async function getLegacyState(): Promise<DeviceLegacyState> {
|
||||
return theState as DeviceLegacyState;
|
||||
}
|
||||
|
||||
async function getSysInfo(
|
||||
lastInfo: Partial<sysInfo.SystemInfo>,
|
||||
): Promise<sysInfo.SystemInfo> {
|
||||
// If hardwareMetrics is false, send null patch for system metrics to cloud API
|
||||
const currentInfo = {
|
||||
...((await config.get('hardwareMetrics'))
|
||||
? await sysInfo.getSystemMetrics()
|
||||
: {
|
||||
cpu_usage: null,
|
||||
memory_usage: null,
|
||||
memory_total: null,
|
||||
storage_usage: null,
|
||||
storage_total: null,
|
||||
storage_block_device: null,
|
||||
cpu_temp: null,
|
||||
cpu_id: null,
|
||||
}),
|
||||
...(await sysInfo.getSystemChecks()),
|
||||
};
|
||||
|
||||
return Object.assign(
|
||||
{} as sysInfo.SystemInfo,
|
||||
...Object.keys(currentInfo).map((key: keyof sysInfo.SystemInfo) => ({
|
||||
[key]: sysInfo.isSignificantChange(
|
||||
key,
|
||||
lastInfo[key] as number,
|
||||
currentInfo[key] as number,
|
||||
)
|
||||
? (currentInfo[key] as number)
|
||||
: (lastInfo[key] as number),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Return current state in a way that the API understands
|
||||
export async function getCurrentForReport(): Promise<DeviceState> {
|
||||
export async function getCurrentForReport(
|
||||
lastReport = {} as DeviceState,
|
||||
): Promise<DeviceState> {
|
||||
const apps = await applicationManager.getState();
|
||||
|
||||
const { name, uuid } = await config.getMany(['name', 'uuid']);
|
||||
const { name, uuid, localMode } = await config.getMany([
|
||||
'name',
|
||||
'uuid',
|
||||
'localMode',
|
||||
]);
|
||||
|
||||
if (!uuid) {
|
||||
throw new InternalInconsistencyError('No uuid found for local device');
|
||||
}
|
||||
|
||||
const omitFromReport = [
|
||||
'update_pending',
|
||||
'update_downloaded',
|
||||
'update_failed',
|
||||
...(localMode ? ['apps', 'logs_channel'] : []),
|
||||
];
|
||||
|
||||
const systemInfo = await getSysInfo(lastReport[uuid] ?? {});
|
||||
|
||||
return {
|
||||
[uuid]: {
|
||||
name,
|
||||
apps,
|
||||
},
|
||||
[uuid]: _.omitBy(
|
||||
{
|
||||
...currentVolatile,
|
||||
...systemInfo,
|
||||
name,
|
||||
apps,
|
||||
},
|
||||
(__, key) => omitFromReport.includes(key),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -607,7 +662,7 @@ export async function getCurrentState(): Promise<InstancedDeviceState> {
|
||||
};
|
||||
}
|
||||
|
||||
export function reportCurrentState(newState: DeviceLegacyReport = {}) {
|
||||
export function reportCurrentState(newState: DeviceReport = {}) {
|
||||
if (newState == null) {
|
||||
newState = {};
|
||||
}
|
||||
|
134
src/lib/json.ts
Normal file
134
src/lib/json.ts
Normal file
@ -0,0 +1,134 @@
|
||||
function isObject(value: unknown): value is object {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates deep equality between javascript
|
||||
* objects
|
||||
*/
|
||||
export function equals<T>(value: T, other: T): boolean {
|
||||
if (isObject(value) && isObject(other)) {
|
||||
const [vProps, oProps] = [value, other].map(
|
||||
(a) => Object.getOwnPropertyNames(a) as Array<keyof T>,
|
||||
);
|
||||
if (vProps.length !== oProps.length) {
|
||||
// If the property lists are different lengths we don't need
|
||||
// to check any further
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise this comparison will catch it. This works even
|
||||
// for arrays as getOwnPropertyNames returns the list of indexes
|
||||
// for each array
|
||||
return vProps.every((key) => equals(value[key], other[key]));
|
||||
}
|
||||
|
||||
return value === other;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the the object equals `{}` or is an empty
|
||||
* array
|
||||
*/
|
||||
export function empty<T>(value: T): boolean {
|
||||
return (Array.isArray(value) && value.length === 0) || equals(value, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difference between the dst object and src object
|
||||
* and return both the object and whether there are any changes
|
||||
*/
|
||||
function diffcmp<T>(src: T, tgt: T, depth = Infinity): [Partial<T>, boolean] {
|
||||
if (!isObject(src) || !isObject(tgt)) {
|
||||
// Always returns tgt in this case, but let the caller
|
||||
// know if there have been any changes
|
||||
return [tgt, src !== tgt];
|
||||
}
|
||||
|
||||
// Compare arrays when reporting differences
|
||||
if (Array.isArray(src) || Array.isArray(tgt) || depth === 0) {
|
||||
return [tgt, !equals(src, tgt)];
|
||||
}
|
||||
|
||||
const r = (Object.getOwnPropertyNames(tgt) as Array<keyof T>)
|
||||
.map((key) => {
|
||||
const [delta, changed] = diffcmp(src[key], tgt[key], depth - 1);
|
||||
return changed ? { [key]: delta } : {};
|
||||
})
|
||||
.concat(
|
||||
(Object.getOwnPropertyNames(src) as Array<keyof T>).map((key) => {
|
||||
const [delta, changed] = diffcmp(src[key], tgt[key], depth - 1);
|
||||
return changed ? { [key]: delta } : {};
|
||||
}),
|
||||
)
|
||||
.reduce((res, delta) => ({ ...res, ...delta }), {} as Partial<T>);
|
||||
|
||||
return [r, Object.keys(r).length > 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difference between the target object and the source object.
|
||||
*
|
||||
* This considers both additive and substractive differences. If both the source
|
||||
* and target elements are arrays, it returns the value of the target array
|
||||
* (no array comparison)
|
||||
* e.g.
|
||||
* ```
|
||||
* // Returns `{b:2}`
|
||||
* diff({a:1}, {a: 1, b:2})
|
||||
*
|
||||
* // Returns `{b: undefined}`
|
||||
* diff({a:1, b:2}, {a: 1})
|
||||
* ```
|
||||
*/
|
||||
export function diff<T>(src: T, tgt: T): Partial<T> {
|
||||
const [res] = diffcmp(src, tgt);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calulate the difference between the target object and the source
|
||||
* object up to the given depth.
|
||||
*
|
||||
* If depth is 0, it compares using `equals` and return the target if they are
|
||||
* different
|
||||
*
|
||||
* shallowDiff(src,tgt, Infinity) return the same result as diff(src, tgt)
|
||||
*/
|
||||
export function shallowDiff<T>(src: T, tgt: T, depth = 1): Partial<T> {
|
||||
const [res] = diffcmp(src, tgt, depth);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes empty branches from the json object
|
||||
*
|
||||
* e.g.
|
||||
* ```
|
||||
* prune({a: 1, b: {}})
|
||||
* // Returns `{a: 1}`
|
||||
* prune({a: 1, b: {}})
|
||||
*
|
||||
* // Returns `{a: 1, b: {c:1}}`
|
||||
* prune({a: 1, b: {c: 1, d: {}}})
|
||||
* ```
|
||||
*/
|
||||
export function prune<T>(obj: T): Partial<T> {
|
||||
if (!isObject(obj) || Array.isArray(obj)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
return (Object.getOwnPropertyNames(obj) as Array<keyof T>)
|
||||
.map((key) => {
|
||||
const prunedChild = prune(obj[key]);
|
||||
if (
|
||||
isObject(obj[key]) &&
|
||||
!Array.isArray(obj) &&
|
||||
equals(prunedChild, {})
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return { [key]: prunedChild };
|
||||
})
|
||||
.reduce((res, delta) => ({ ...res, ...delta }), {} as Partial<T>);
|
||||
}
|
@ -29,7 +29,7 @@ export type DeviceLegacyReport = Partial<{
|
||||
update_failed: boolean;
|
||||
update_pending: boolean;
|
||||
update_downloaded: boolean;
|
||||
logs_channel: null;
|
||||
logs_channel: string | null;
|
||||
mac_address: string | null;
|
||||
}>;
|
||||
|
||||
@ -85,12 +85,12 @@ export type DeviceReport = {
|
||||
os_variant?: string | null; // TODO: Should these purely come from the os app?
|
||||
supervisor_version?: string; // TODO: Should this purely come from the supervisor app?
|
||||
provisioning_progress?: number | null; // TODO: should this be reported as part of the os app?
|
||||
provisioning_state?: string | null; // TODO: should this be reported as part of the os app?
|
||||
provisioning_state?: string; // TODO: should this be reported as part of the os app?
|
||||
ip_address?: string;
|
||||
mac_address?: string | null;
|
||||
api_port?: number; // TODO: should this be reported as part of the supervisor app?
|
||||
api_secret?: string | null; // TODO: should this be reported as part of the supervisor app?
|
||||
logs_channel?: string; // TODO: should this be reported as part of the supervisor app? or should it not be reported anymore at all?
|
||||
logs_channel?: string | null; // TODO: should this be reported as part of the supervisor app? or should it not be reported anymore at all?
|
||||
memory_usage?: number;
|
||||
memory_total?: number;
|
||||
storage_block_device?: string;
|
||||
@ -100,7 +100,7 @@ export type DeviceReport = {
|
||||
cpu_usage?: number;
|
||||
cpu_id?: string;
|
||||
is_undervolted?: boolean;
|
||||
// TODO: these are ignored by the API but are used by supervisor local API, remove?
|
||||
// TODO: these are ignored by the API but are used by supervisor local API
|
||||
update_failed?: boolean;
|
||||
update_pending?: boolean;
|
||||
update_downloaded?: boolean;
|
||||
|
159
test/src/lib/json.spec.ts
Normal file
159
test/src/lib/json.spec.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { expect } from 'chai';
|
||||
import { equals, diff, prune, shallowDiff } from '../../../src/lib/json';
|
||||
|
||||
describe('JSON utils', () => {
|
||||
describe('equals', () => {
|
||||
it('should compare non-objects', () => {
|
||||
expect(equals(0, 1)).to.be.false;
|
||||
expect(equals(1111, 'a' as any)).to.be.false;
|
||||
expect(equals(1111, 2222)).to.be.false;
|
||||
expect(equals('aaa', 'bbb')).to.be.false;
|
||||
expect(equals('aaa', 'aaa')).to.be.true;
|
||||
expect(equals(null, null)).to.be.true;
|
||||
expect(equals(null, undefined)).to.be.false;
|
||||
expect(equals([], [])).to.be.true;
|
||||
expect(equals([1, 2, 3], [1, 2, 3])).to.be.true;
|
||||
expect(equals([1, 2, 3], [1, 2])).to.be.false;
|
||||
expect(equals([], []), 'empty arrays').to.be.true;
|
||||
});
|
||||
|
||||
it('should compare objects recursively', () => {
|
||||
expect(equals({}, {}), 'empty objects').to.be.true;
|
||||
expect(equals({ a: 1 }, { a: 1 }), 'single level objects').to.be.true;
|
||||
expect(equals({ a: 1 }, { a: 2 }), 'differing value single level objects')
|
||||
.to.be.false;
|
||||
expect(equals({ a: 1 }, { b: 1 }), 'differing keys single level objects');
|
||||
expect(
|
||||
equals({ a: 1 }, { b: 1, c: 2 }),
|
||||
'differing keys single level objects',
|
||||
).to.be.false;
|
||||
expect(equals({ a: { b: 1 } }, { a: { b: 1 } }), 'multiple level objects')
|
||||
.to.be.true;
|
||||
expect(
|
||||
equals({ a: { b: 1 } }, { a: { b: 1, c: 2 } }),
|
||||
'extra keys in multiple level objects',
|
||||
).to.be.false;
|
||||
expect(
|
||||
equals({ a: { b: 1 }, c: 2 }, { a: { b: 1 } }),
|
||||
'source object with extra keys',
|
||||
).to.be.false;
|
||||
expect(
|
||||
equals({ a: { b: 1 } }, { a: { b: 1 }, c: 2 }),
|
||||
'other object with extra keys',
|
||||
).to.be.false;
|
||||
expect(
|
||||
equals({ a: { b: 1 }, c: 2 }, { a: { b: 1 }, c: 2 }),
|
||||
'multiple level objects with extra keys',
|
||||
).to.be.true;
|
||||
expect(
|
||||
equals({ a: { b: 1 }, d: 2 }, { a: { b: 1 }, c: 2 }),
|
||||
'multiple level objects with same number of keys',
|
||||
).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('diff', () => {
|
||||
it('when comparing non-objects or arrays, always returns the target value', () => {
|
||||
expect(diff(1, 2)).to.equal(2);
|
||||
expect(diff(1, 'a' as any)).to.equal('a');
|
||||
expect(diff(1.1, 2)).to.equal(2);
|
||||
expect(diff('aaa', 'bbb')).to.equal('bbb');
|
||||
expect(diff({}, 'bbb' as any)).to.equal('bbb');
|
||||
expect(diff([1, 2, 3], [3, 4, 5])).to.deep.equal([3, 4, 5]);
|
||||
});
|
||||
|
||||
it('when comparing objects, calculates differences recursively', () => {
|
||||
// Reports all changes
|
||||
expect(diff({ a: 1 }, { b: 1 })).to.deep.equal({ a: undefined, b: 1 });
|
||||
|
||||
// Only reports array changes if arrays are different
|
||||
expect(diff({ a: [1, 2] }, { a: [1, 2] })).to.deep.equal({});
|
||||
|
||||
// Multiple key comparisons
|
||||
expect(diff({ a: 1, b: 1 }, { b: 2 })).to.deep.equal({
|
||||
a: undefined,
|
||||
b: 2,
|
||||
});
|
||||
|
||||
// Multiple target keys
|
||||
expect(diff({ a: 1 }, { b: 2, c: 1 })).to.deep.equal({
|
||||
a: undefined,
|
||||
b: 2,
|
||||
c: 1,
|
||||
});
|
||||
|
||||
// Removing a branch
|
||||
expect(diff({ a: 1, b: { c: 1 } }, { a: 1 })).to.deep.equal({
|
||||
b: undefined,
|
||||
});
|
||||
|
||||
// If the arrays are different, return target array
|
||||
expect(diff({ a: [1, 2] }, { a: [2, 3] })).to.deep.equal({ a: [2, 3] });
|
||||
|
||||
// Value to object
|
||||
expect(diff({ a: 1 }, { a: { c: 1 }, b: 2 })).to.deep.equal({
|
||||
a: { c: 1 },
|
||||
b: 2,
|
||||
});
|
||||
|
||||
// Changes in nested object
|
||||
expect(diff({ a: { c: 1 } }, { a: { c: 1, d: 2 }, b: 3 })).to.deep.equal({
|
||||
a: { d: 2 },
|
||||
b: 3,
|
||||
});
|
||||
|
||||
// Multiple level nested objects with value removal
|
||||
expect(
|
||||
diff({ a: { c: { f: 1, g: 2 } } }, { a: { c: { f: 2 }, d: 2 }, b: 3 }),
|
||||
).to.deep.equal({
|
||||
a: { c: { f: 2, g: undefined }, d: 2 },
|
||||
b: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shallowDiff', () => {
|
||||
it('compares objects only to the given depth', () => {
|
||||
expect(shallowDiff({ a: 1 }, { a: 1 }, 0)).to.deep.equal({ a: 1 });
|
||||
expect(shallowDiff({ a: 1 }, { a: 1 }, 1)).to.deep.equal({});
|
||||
expect(shallowDiff({ a: 1 }, { a: 1 }, 2)).to.deep.equal({});
|
||||
expect(shallowDiff({ a: 1, b: 1 }, { a: 1 }, 1)).to.deep.equal({
|
||||
b: undefined,
|
||||
});
|
||||
expect(
|
||||
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }, 1),
|
||||
).to.deep.equal({});
|
||||
expect(
|
||||
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2, d: 3 } }, 1),
|
||||
).to.deep.equal({ b: { c: 2, d: 3 } });
|
||||
expect(
|
||||
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2, d: 3 } }, 2),
|
||||
).to.deep.equal({ b: { d: 3 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('prune', () => {
|
||||
it('does not remove empty arrays or other "empty values"', () => {
|
||||
expect(prune([])).to.deep.equal([]);
|
||||
expect(prune([0])).to.deep.equal([0]);
|
||||
expect(prune(0)).to.deep.equal(0);
|
||||
expect(prune({ a: 0 })).to.deep.equal({ a: 0 });
|
||||
expect(prune({ a: [] })).to.deep.equal({ a: [] });
|
||||
expect(prune({ a: [], b: 0 })).to.deep.equal({ a: [], b: 0 });
|
||||
});
|
||||
it('removes empty branches from a json object', () => {
|
||||
expect(prune({})).to.deep.equal({});
|
||||
expect(prune({ a: {} })).to.deep.equal({});
|
||||
expect(prune({ a: { b: {} } })).to.deep.equal({});
|
||||
expect(prune({ a: 1, b: {} })).to.deep.equal({ a: 1 });
|
||||
expect(prune({ a: 1, b: {}, c: { d: 2, e: {} } })).to.deep.equal({
|
||||
a: 1,
|
||||
c: { d: 2 },
|
||||
});
|
||||
expect(prune({ a: 1, b: {}, c: { d: 2, e: [] } })).to.deep.equal({
|
||||
a: 1,
|
||||
c: { d: 2, e: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user