Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/devtools-common/src/utils/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,16 @@ describe('formatBytes', () => {
it('treats negative decimals as zero', () => {
expect(formatBytes(1536, -1)).toBe('2 KB');
});

it('handles sub-byte fractional values without undefined unit (the fix)', () => {
const result = formatBytes(0.0001);
expect(result).not.toContain('undefined');
expect(result).toContain('B');
});

it('handles very small positive values', () => {
const result = formatBytes(0.5);
expect(result).toContain('B');
expect(result).not.toContain('undefined');
});
});
2 changes: 1 addition & 1 deletion packages/devtools-common/src/utils/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];

const i = Math.floor(Math.log(absBytes) / Math.log(k));
const i = Math.max(0, Math.floor(Math.log(absBytes) / Math.log(k)));

return sign + parseFloat((absBytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
Expand Down
25 changes: 25 additions & 0 deletions packages/feature-flags/src/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,4 +488,29 @@ describe('FeatureFlagManager', () => {
newManager.destroy();
});
});

describe('refreshInterval safety (the fix)', () => {
it('defaults refreshInterval to 30000 when explicitly passed as undefined', () => {
const mgr = new FeatureFlagManager({
storage,
autoRefresh: false,
refreshInterval: undefined,
});

// Access internal options via any cast
expect((mgr as any).options.refreshInterval).toBe(30000);
mgr.destroy();
});

it('uses provided refreshInterval when valid', () => {
const mgr = new FeatureFlagManager({
storage,
autoRefresh: false,
refreshInterval: 5000,
});

expect((mgr as any).options.refreshInterval).toBe(5000);
mgr.destroy();
});
});
});
4 changes: 2 additions & 2 deletions packages/feature-flags/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ export class FeatureFlagManager {
this.options = {
persistOverrides: true,
autoRefresh: false,
refreshInterval: 30000,
...options
...options,
refreshInterval: options.refreshInterval ?? 30000,
};

this.storage = options.storage ||
Expand Down
25 changes: 24 additions & 1 deletion packages/shared-components/src/components/TreeView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TreeView, TreeNode } from './TreeView';

Expand Down Expand Up @@ -130,4 +130,27 @@ describe('TreeView', () => {
expect(screen.getByText('Root Node')).toBeTruthy();
expect(screen.getByText('Leaf Node')).toBeTruthy();
});

it('respects selectedIds=[] to clear selection (the fix)', () => {
const onSelect = vi.fn();
// First render with a selected node
const { rerender } = render(
<TreeView data={sampleData} selectedIds={['root']} onSelect={onSelect} />
);

// Now pass empty array to clear — should NOT fall back to internal state
rerender(
<TreeView data={sampleData} selectedIds={[]} onSelect={onSelect} />
);

// No node should have selection styling — verify root node is rendered but not selected
expect(screen.getByText('Root Node')).toBeTruthy();
// The component renders — this ensures it doesn't crash with empty selectedIds
});

it('uses internal selection when selectedIds is not provided', () => {
render(<TreeView data={sampleData} />);
expect(screen.getByText('Root Node')).toBeTruthy();
expect(screen.getByText('Leaf Node')).toBeTruthy();
});
});
12 changes: 7 additions & 5 deletions packages/shared-components/src/components/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export interface TreeViewProps<T = any> {

export function TreeView<T = any>({
data,
selectedIds = [],
selectedIds: controlledSelectedIds,
multiSelect = false,
onSelect,
onMultiSelect,
Expand Down Expand Up @@ -87,7 +87,7 @@ export function TreeView<T = any>({
new Set(defaultExpandedIds)
);
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(
new Set(selectedIds)
new Set(controlledSelectedIds)
);

const expandedSet = useMemo(() =>
Expand All @@ -97,9 +97,11 @@ export function TreeView<T = any>({
[controlledExpandedIds, internalExpandedIds]
);

const selectedSet = useMemo(() =>
new Set(selectedIds.length > 0 ? selectedIds : internalSelectedIds),
[selectedIds, internalSelectedIds]
const selectedSet = useMemo(() =>
controlledSelectedIds !== undefined
? new Set(controlledSelectedIds)
: internalSelectedIds,
[controlledSelectedIds, internalSelectedIds]
);

// Toggle expansion
Expand Down
13 changes: 4 additions & 9 deletions plugins/auth-permissions-mock/src/core/devtools-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,7 @@ class AuthMockDevToolsStore {
}

recordStorageOperation(operation: StorageOperation) {
this.state.storageOperations.push(operation);

// Keep only last 100 operations
if (this.state.storageOperations.length > 100) {
this.state.storageOperations = this.state.storageOperations.slice(-100);
}
this.state.storageOperations = [...this.state.storageOperations, operation].slice(-100);

this.notifyListeners();
}
Expand Down Expand Up @@ -258,17 +253,17 @@ class AuthMockDevToolsStore {
}

addScenario(scenario: MockScenario) {
this.state.scenarios.push(scenario);
this.state.scenarios = [...this.state.scenarios, scenario];
this.notifyListeners();
}

addRole(role: Role) {
this.state.roles.push(role);
this.state.roles = [...this.state.roles, role];
this.notifyListeners();
}

addPermission(permission: Permission) {
this.state.permissions.push(permission);
this.state.permissions = [...this.state.permissions, permission];
this.notifyListeners();
}

Expand Down
64 changes: 64 additions & 0 deletions plugins/graphql-devtools/src/core/__tests__/devtools-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @vitest-environment jsdom
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { GraphQLDevToolsStore } from '../devtools-store';
import type { GraphQLOperation } from '../../types';

function makeOperation(overrides: Partial<GraphQLOperation> = {}): GraphQLOperation {
return {
id: `op-${Math.random().toString(36).substring(2)}`,
operationType: 'query',
query: '{ users { id name } }',
timestamp: Date.now(),
status: 'success',
...overrides,
};
}

describe('GraphQLDevToolsStore — averageExecutionTime', () => {
let store: GraphQLDevToolsStore;

beforeEach(() => {
store = new GraphQLDevToolsStore();
});

it('does not produce NaN on first operation with executionTime (the fix)', () => {
const op = makeOperation({ executionTime: 50 });
store.dispatch({ type: 'operations/add', payload: op });

const state = store.getSnapshot();
expect(Number.isNaN(state.performance.averageExecutionTime)).toBe(false);
expect(state.performance.averageExecutionTime).toBe(50);
});

it('calculates correct average over multiple operations', () => {
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: 100 }) });
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: 200 }) });
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: 300 }) });

const state = store.getSnapshot();
expect(state.performance.averageExecutionTime).toBe(200);
});

it('ignores operations without executionTime', () => {
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: 100 }) });
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: undefined }) });
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: 300 }) });

const state = store.getSnapshot();
// Average of [100, 300] = 200
expect(state.performance.averageExecutionTime).toBe(200);
});

it('tracks slowest and fastest operations', () => {
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: 100 }) });
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: 500 }) });
store.dispatch({ type: 'operations/add', payload: makeOperation({ executionTime: 200 }) });

const state = store.getSnapshot();
expect(state.performance.slowestOperation?.executionTime).toBe(500);
expect(state.performance.fastestOperation?.executionTime).toBe(100);
});
});
9 changes: 6 additions & 3 deletions plugins/graphql-devtools/src/core/devtools-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,10 +468,13 @@ export class GraphQLDevToolsStore {

// Update execution time metrics
if (operation.executionTime !== undefined) {
const allOperations = this.state.operations.filter(op => op.executionTime !== undefined);
// Include the current operation since this.state.operations is the pre-add snapshot
const allOperations = [...this.state.operations, operation].filter(op => op.executionTime !== undefined);
const executionTimes = allOperations.map(op => op.executionTime as number);

updated.averageExecutionTime = executionTimes.reduce((sum, time) => sum + time, 0) / executionTimes.length;

updated.averageExecutionTime = executionTimes.length > 0
? executionTimes.reduce((sum, time) => sum + time, 0) / executionTimes.length
: 0;

if (!updated.slowestOperation || operation.executionTime > (updated.slowestOperation.executionTime || 0)) {
updated.slowestOperation = operation;
Expand Down
Loading