| // 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}, |
| ]); |
| }); |
| }); |