blob: 13d1bc24d6e6645a9e70cb696eea015cbc7d025f [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type {DashboardTabState, DataExplorerTab} from './data_explorer';
import {
isSerializedTabExport,
deserializeDashboardsFromExport,
type SerializedTabExport,
} from './graph_io';
import {
serializeDashboardsForTab,
type SerializedDashboard,
} from './data_explorer_tabs_storage';
describe('isSerializedTabExport', () => {
test('returns true for valid tab export', () => {
const obj: SerializedTabExport = {
version: 1,
title: 'My Tab',
graph: '{"nodes":[],"rootNodeIds":[]}',
};
expect(isSerializedTabExport(obj)).toBe(true);
});
test('returns true for tab export with dashboards', () => {
const obj: SerializedTabExport = {
version: 1,
title: 'My Tab',
graph: '{}',
dashboards: [{id: 'd1'}],
};
expect(isSerializedTabExport(obj)).toBe(true);
});
test('returns true with extra unknown fields', () => {
const obj = {version: 1, title: 'x', graph: '{}', extra: 'ignored'};
expect(isSerializedTabExport(obj)).toBe(true);
});
test('returns false for null', () => {
expect(isSerializedTabExport(null)).toBe(false);
});
test('returns false for undefined', () => {
expect(isSerializedTabExport(undefined)).toBe(false);
});
test('returns false for primitives', () => {
expect(isSerializedTabExport(42)).toBe(false);
expect(isSerializedTabExport('string')).toBe(false);
expect(isSerializedTabExport(true)).toBe(false);
});
test('returns false for arrays', () => {
expect(isSerializedTabExport([1, 2, 3])).toBe(false);
});
test('returns false for empty object', () => {
expect(isSerializedTabExport({})).toBe(false);
});
test('returns false for plain graph export', () => {
const plainGraph = {nodes: [], rootNodeIds: []};
expect(isSerializedTabExport(plainGraph)).toBe(false);
});
test('returns false for missing graph field', () => {
expect(isSerializedTabExport({version: 1, title: 'x'})).toBe(false);
});
test('returns false for missing title field', () => {
expect(isSerializedTabExport({version: 1, graph: '{}'})).toBe(false);
});
test('returns false for missing version field', () => {
expect(isSerializedTabExport({title: 'x', graph: '{}'})).toBe(false);
});
test('returns false for wrong types', () => {
expect(isSerializedTabExport({version: 'a', title: 'x', graph: '{}'})).toBe(
false,
);
expect(isSerializedTabExport({version: 1, title: 42, graph: '{}'})).toBe(
false,
);
expect(isSerializedTabExport({version: 1, title: 'x', graph: 123})).toBe(
false,
);
});
});
describe('deserializeDashboardsFromExport', () => {
test('returns undefined for undefined input', () => {
expect(deserializeDashboardsFromExport(undefined)).toBeUndefined();
});
test('returns undefined for empty array', () => {
expect(deserializeDashboardsFromExport([])).toBeUndefined();
});
// Bug fix: dashboards field could be a non-array type from malformed JSON
test('returns undefined for non-array input (string)', () => {
expect(
deserializeDashboardsFromExport('invalid' as unknown),
).toBeUndefined();
});
test('returns undefined for non-array input (number)', () => {
expect(deserializeDashboardsFromExport(42 as unknown)).toBeUndefined();
});
test('returns undefined for non-array input (object)', () => {
expect(
deserializeDashboardsFromExport({foo: 'bar'} as unknown),
).toBeUndefined();
});
test('skips entries that are null', () => {
const result = deserializeDashboardsFromExport([
null,
{id: 'db1'},
] as unknown as SerializedDashboard[]);
expect(result?.length).toBe(1);
expect(result?.[0].id).toBe('db1');
});
test('skips entries that are primitives', () => {
const result = deserializeDashboardsFromExport([
42,
'str',
{id: 'db1'},
] as unknown as SerializedDashboard[]);
expect(result?.length).toBe(1);
expect(result?.[0].id).toBe('db1');
});
test('skips entries with missing id', () => {
const result = deserializeDashboardsFromExport([
{noId: true},
{id: 'db1'},
] as unknown as SerializedDashboard[]);
expect(result?.length).toBe(1);
expect(result?.[0].id).toBe('db1');
});
test('accepts entries with only id', () => {
const result = deserializeDashboardsFromExport([
{id: 'db1'},
{id: 'db2'},
] as unknown as SerializedDashboard[]);
expect(result?.length).toBe(2);
expect(result?.[0].id).toBe('db1');
expect(result?.[1].id).toBe('db2');
});
test('skips entries with non-string id', () => {
const result = deserializeDashboardsFromExport([
{id: 123},
{id: 'db1'},
] as unknown as SerializedDashboard[]);
expect(result?.length).toBe(1);
expect(result?.[0].id).toBe('db1');
});
test('returns undefined when ALL entries are invalid', () => {
const result = deserializeDashboardsFromExport([
null,
42,
{noId: true},
{id: 123},
] as unknown as SerializedDashboard[]);
expect(result).toBeUndefined();
});
test('hydrates dashboards with valid label items', () => {
const serialized: SerializedDashboard[] = [
{
id: 'db1',
items: [{kind: 'label', id: 'lbl1', text: 'Hello', col: 1, row: 2}],
},
];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.length).toBe(1);
expect(result?.[0].id).toBe('db1');
expect(result?.[0].items.length).toBe(1);
expect(result?.[0].brushFilters).toEqual(new Map());
});
test('filters out invalid items during hydration', () => {
const serialized: SerializedDashboard[] = [
{
id: 'db1',
items: [
{kind: 'label', id: 'lbl1', text: 'Good'},
{kind: 'label'}, // missing id and text
{kind: 'chart'}, // missing sourceNodeId and config
{kind: 'unknown_kind', id: 'x', text: 'x'},
42, // not an object
] as unknown[],
},
];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.length).toBe(1);
expect(result?.[0].items.length).toBe(1);
expect((result?.[0].items[0] as {id: string}).id).toBe('lbl1');
});
test('returns empty items when all items are invalid', () => {
const serialized: SerializedDashboard[] = [
{
id: 'db1',
items: [
{kind: 'chart'}, // missing required fields
null,
] as unknown[],
},
];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.length).toBe(1);
expect(result?.[0].items).toEqual([]);
});
test('hydrates dashboards with brush filters', () => {
const serialized: SerializedDashboard[] = [
{
id: 'db1',
brushFilters: {
node1: [{column: 'ts', op: '>=', value: 100}],
},
},
];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.[0].brushFilters.size).toBe(1);
expect(result?.[0].brushFilters.get('node1')).toEqual([
{column: 'ts', op: '>=', value: 100},
]);
});
test('drops brush filters with invalid ops', () => {
const serialized: SerializedDashboard[] = [
{
id: 'db1',
brushFilters: {
node1: [
{column: 'ts', op: '>=', value: 100},
{column: 'ts', op: 'INVALID', value: 0},
],
},
},
];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.[0].brushFilters.get('node1')?.length).toBe(1);
expect(result?.[0].brushFilters.get('node1')?.[0].op).toBe('>=');
});
test('drops brush filters with missing column', () => {
const serialized: SerializedDashboard[] = [
{
id: 'db1',
brushFilters: {
node1: [{op: '>=', value: 100}],
} as unknown as Record<string, unknown[]>,
},
];
const result = deserializeDashboardsFromExport(serialized);
// All filters for node1 are invalid, so the entry is dropped entirely
expect(result?.[0].brushFilters.size).toBe(0);
});
test('ignores non-object brushFilters (array)', () => {
const serialized = [
{
id: 'db1',
brushFilters: [{column: 'ts', op: '>='}],
},
] as unknown as SerializedDashboard[];
const result = deserializeDashboardsFromExport(serialized);
// brushFilters is an array, not a record — should fall back to empty map
expect(result?.[0].brushFilters).toEqual(new Map());
});
test('ignores non-object brushFilters (string)', () => {
const serialized = [
{
id: 'db1',
brushFilters: 'invalid',
},
] as unknown as SerializedDashboard[];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.[0].brushFilters).toEqual(new Map());
});
test('handles dashboards with no items field', () => {
const serialized: SerializedDashboard[] = [{id: 'db1'}];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.[0].items).toEqual([]);
});
test('hydrates multiple dashboards', () => {
const serialized: SerializedDashboard[] = [{id: 'db1'}, {id: 'db2'}];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.length).toBe(2);
expect(result?.[0].id).toBe('db1');
expect(result?.[1].id).toBe('db2');
});
test('supports is null brush filter (no value field)', () => {
const serialized: SerializedDashboard[] = [
{
id: 'db1',
brushFilters: {
node1: [{column: 'name', op: 'is null'}],
},
},
];
const result = deserializeDashboardsFromExport(serialized);
expect(result?.[0].brushFilters.get('node1')).toEqual([
{column: 'name', op: 'is null'},
]);
});
});
describe('serializeDashboardsForTab', () => {
function makeTab(dashboards: DashboardTabState[]): DataExplorerTab {
return {
id: 'tab1',
title: 'Test Tab',
state: {
rootNodes: [],
selectedNodes: new Set(),
nodeLayouts: new Map(),
labels: [],
},
dashboards,
};
}
test('returns undefined for tab with no dashboards', () => {
const tab = makeTab([]);
expect(serializeDashboardsForTab(tab)).toBeUndefined();
});
test('serializes tab with empty dashboard items', () => {
const tab = makeTab([{id: 'db1', items: [], brushFilters: new Map()}]);
const result = serializeDashboardsForTab(tab);
expect(result).toBeDefined();
expect(result?.length).toBe(1);
expect(result?.[0].items).toBeUndefined();
expect(result?.[0].brushFilters).toBeUndefined();
});
test('serializes dashboard with label items', () => {
const tab = makeTab([
{
id: 'db1',
items: [{kind: 'label', id: 'lbl1', text: 'Hello', col: 0, row: 0}],
brushFilters: new Map(),
},
]);
const result = serializeDashboardsForTab(tab);
expect(result?.length).toBe(1);
expect(result?.[0].id).toBe('db1');
expect(result?.[0].items?.length).toBe(1);
expect(result?.[0].brushFilters).toBeUndefined();
});
test('serializes brush filters with BigInt conversion', () => {
const filters = new Map([
['node1', [{column: 'ts', op: '>=' as const, value: BigInt(100)}]],
]);
const tab = makeTab([
{
id: 'db1',
items: [{kind: 'label', id: 'lbl1', text: 'Hello', col: 0, row: 0}],
brushFilters: filters,
},
]);
const result = serializeDashboardsForTab(tab);
expect(result?.[0].brushFilters).toBeDefined();
expect(result?.[0].brushFilters?.['node1']?.[0]).toEqual({
column: 'ts',
op: '>=',
value: 100,
});
});
test('serializes multiple dashboards', () => {
const tab = makeTab([
{
id: 'db1',
items: [{kind: 'label', id: 'l1', text: 'A', col: 0, row: 0}],
brushFilters: new Map(),
},
{
id: 'db2',
items: [{kind: 'label', id: 'l2', text: 'B', col: 1, row: 1}],
brushFilters: new Map(),
},
]);
const result = serializeDashboardsForTab(tab);
expect(result?.length).toBe(2);
expect(result?.[0].id).toBe('db1');
expect(result?.[1].id).toBe('db2');
});
test('does not include graphTabId in output', () => {
const tab = makeTab([
{
id: 'db1',
items: [{kind: 'label', id: 'l1', text: 'x', col: 0, row: 0}],
brushFilters: new Map(),
},
]);
const result = serializeDashboardsForTab(tab);
expect(result?.[0]).not.toHaveProperty('graphTabId');
});
test('serializes brush filters for is null op without value', () => {
const filters = new Map([
['node1', [{column: 'name', op: 'is null' as const}]],
]);
const tab = makeTab([
{
id: 'db1',
items: [{kind: 'label', id: 'l1', text: 'x', col: 0, row: 0}],
brushFilters: filters,
},
]);
const result = serializeDashboardsForTab(tab);
expect(result?.[0].brushFilters?.['node1']?.[0]).toEqual({
column: 'name',
op: 'is null',
});
});
});
describe('tab export/import round-trip', () => {
test('hydrated dashboards match original structure', () => {
const original: DashboardTabState[] = [
{
id: 'db1',
items: [
{kind: 'label', id: 'lbl1', text: 'Test Label', col: 1, row: 2},
],
brushFilters: new Map([
['node1', [{column: 'dur', op: '>=' as const, value: 500}]],
]),
},
{
id: 'db2',
items: [],
brushFilters: new Map(),
},
];
const tab: DataExplorerTab = {
id: 'tab1',
title: 'Test Tab',
state: {
rootNodes: [],
selectedNodes: new Set(),
nodeLayouts: new Map(),
labels: [],
},
dashboards: original,
};
// Serialize
const serialized = serializeDashboardsForTab(tab);
expect(serialized).toBeDefined();
// Hydrate back
const hydrated = deserializeDashboardsFromExport(serialized);
expect(hydrated).toBeDefined();
// Both dashboards get serialized (even the empty one)
expect(hydrated?.length).toBe(2);
// First dashboard: has items and brush filters
expect(hydrated?.[0].id).toBe('db1');
expect(hydrated?.[0].items.length).toBe(1);
expect(hydrated?.[0].brushFilters.size).toBe(1);
expect(hydrated?.[0].brushFilters.get('node1')).toEqual([
{column: 'dur', op: '>=', value: 500},
]);
// Second dashboard: empty items, no filters
expect(hydrated?.[1].id).toBe('db2');
expect(hydrated?.[1].items).toEqual([]);
expect(hydrated?.[1].brushFilters).toEqual(new Map());
});
test('round-trip through JSON.stringify/parse preserves data', () => {
const tab: DataExplorerTab = {
id: 'tab1',
title: 'Test Tab',
state: {
rootNodes: [],
selectedNodes: new Set(),
nodeLayouts: new Map(),
labels: [],
},
dashboards: [
{
id: 'db1',
items: [{kind: 'label', id: 'lbl1', text: 'Label', col: 1, row: 1}],
brushFilters: new Map([
['n1', [{column: 'ts', op: '>=' as const, value: 1000}]],
]),
},
],
};
const serialized = serializeDashboardsForTab(tab);
// Simulate going through JSON file (as exportTab does)
const jsonString = JSON.stringify(serialized);
const parsed = JSON.parse(jsonString) as SerializedDashboard[];
const hydrated = deserializeDashboardsFromExport(parsed);
expect(hydrated?.length).toBe(1);
expect(hydrated?.[0].id).toBe('db1');
expect(hydrated?.[0].items.length).toBe(1);
expect(hydrated?.[0].brushFilters.get('n1')).toEqual([
{column: 'ts', op: '>=', value: 1000},
]);
});
});