@cccsaurora/howler-ui 2.19.0-dev.1143 → 2.19.0-dev.1151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import { setupReactRouterMock } from '@cccsaurora/howler-ui/tests/mocks';
5
+ import { vi } from 'vitest';
6
+ setupReactRouterMock();
7
+ import CustomIconButton from './CustomIconButton';
8
+ describe('CustomIconButton', () => {
9
+ afterAll(() => vi.resetModules());
10
+ describe('rendering', () => {
11
+ it('renders a button element', () => {
12
+ render(_jsx(CustomIconButton, { "aria-label": "test", children: "icon" }));
13
+ expect(screen.getByRole('button', { name: 'test' })).toBeInTheDocument();
14
+ });
15
+ it('renders children inside the button', () => {
16
+ render(_jsx(CustomIconButton, { children: "\u2605" }));
17
+ expect(screen.getByText('★')).toBeInTheDocument();
18
+ });
19
+ it('renders with MUI IconButton classes', () => {
20
+ render(_jsx(CustomIconButton, { children: "i" }));
21
+ expect(screen.getByRole('button')).toHaveClass('MuiIconButton-root');
22
+ });
23
+ });
24
+ describe('disabled state', () => {
25
+ it('is disabled when disabled prop is true', () => {
26
+ render(_jsx(CustomIconButton, { disabled: true, children: "icon" }));
27
+ expect(screen.getByRole('button')).toBeDisabled();
28
+ });
29
+ it('is disabled when progress is truthy', () => {
30
+ render(_jsx(CustomIconButton, { progress: true, children: "icon" }));
31
+ expect(screen.getByRole('button')).toBeDisabled();
32
+ });
33
+ it('is NOT disabled when progress is set but clickableWithProgress=true', () => {
34
+ render(_jsx(CustomIconButton, { progress: true, clickableWithProgress: true, children: "icon" }));
35
+ expect(screen.getByRole('button')).not.toBeDisabled();
36
+ });
37
+ });
38
+ describe('progress indicator', () => {
39
+ it('renders a CircularProgress when progress is truthy', () => {
40
+ render(_jsx(CustomIconButton, { progress: true, children: "icon" }));
41
+ // CircularProgress renders an svg with role="progressbar"
42
+ expect(document.querySelector('circle')).toBeInTheDocument();
43
+ });
44
+ it('does not render CircularProgress when progress is falsy', () => {
45
+ render(_jsx(CustomIconButton, { children: "icon" }));
46
+ expect(document.querySelector('[role="progressbar"]')).not.toBeInTheDocument();
47
+ });
48
+ });
49
+ describe('tooltip', () => {
50
+ it('wraps the button in a Tooltip when tooltip prop is provided', () => {
51
+ render(_jsx(CustomIconButton, { tooltip: "Save", children: "icon" }));
52
+ // The button is wrapped in a <span> when a Tooltip is used
53
+ const span = screen.getByRole('button').closest('span');
54
+ expect(span).toBeInTheDocument();
55
+ });
56
+ it('does not add a surrounding span when no tooltip is given', () => {
57
+ render(_jsx(CustomIconButton, { children: "icon" }));
58
+ const button = screen.getByRole('button');
59
+ // Without tooltip the immediate parent should NOT be a span wrapper
60
+ expect(button.parentElement?.tagName).not.toBe('SPAN');
61
+ });
62
+ });
63
+ describe('route link', () => {
64
+ it('wraps the button in a Link when route prop is provided', () => {
65
+ render(_jsx(CustomIconButton, { route: "/hits", children: "icon" }));
66
+ expect(screen.getByRole('link')).toBeInTheDocument();
67
+ });
68
+ it('does not render a link when no route is given', () => {
69
+ render(_jsx(CustomIconButton, { children: "icon" }));
70
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
71
+ });
72
+ });
73
+ describe('href link', () => {
74
+ it('wraps the button in an anchor tag when href is provided', () => {
75
+ render(_jsx(CustomIconButton, { href: "https://example.com", children: "icon" }));
76
+ const anchor = screen.getByRole('link');
77
+ expect(anchor).toBeInTheDocument();
78
+ expect(anchor).toHaveAttribute('href', 'https://example.com');
79
+ });
80
+ });
81
+ });
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import { describe, expect, it } from 'vitest';
5
+ import FlexVertical from './FlexVertical';
6
+ describe('FlexVertical', () => {
7
+ it('renders a div with display:flex and flex-direction:column', () => {
8
+ const { container } = render(_jsx(FlexVertical, { children: _jsx("span", { children: "child" }) }));
9
+ const div = container.firstChild;
10
+ expect(div).toBeInTheDocument();
11
+ expect(div).toHaveStyle({ display: 'flex', flexDirection: 'column' });
12
+ });
13
+ it('renders children inside the flex container', () => {
14
+ render(_jsx(FlexVertical, { children: _jsx("span", { id: "inner", children: "hello" }) }));
15
+ expect(screen.getByTestId('inner')).toBeInTheDocument();
16
+ });
17
+ it('renders multiple children', () => {
18
+ render(_jsxs(FlexVertical, { children: [_jsx("span", { id: "a", children: "A" }), _jsx("span", { id: "b", children: "B" })] }));
19
+ expect(screen.getByTestId('a')).toBeInTheDocument();
20
+ expect(screen.getByTestId('b')).toBeInTheDocument();
21
+ });
22
+ it('applies the default flex value of 1', () => {
23
+ const { container } = render(_jsx(FlexVertical, { children: _jsx("span", { children: "child" }) }));
24
+ const div = container.firstChild;
25
+ expect(div).toHaveStyle({ flex: 1 });
26
+ });
27
+ it('applies a custom flex value when provided', () => {
28
+ const { container } = render(_jsx(FlexVertical, { flex: 2, children: _jsx("span", { children: "child" }) }));
29
+ const div = container.firstChild;
30
+ expect(div).toHaveStyle({ flex: 2 });
31
+ });
32
+ });
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import { describe, expect, it } from 'vitest';
5
+ import VSBoxContent from './VSBoxContent';
6
+ describe('VSBoxContent', () => {
7
+ it('renders children inside a div with data-vsbox-content attribute', () => {
8
+ render(_jsx(VSBoxContent, { children: _jsx("span", { children: "hello" }) }));
9
+ const el = document.querySelector('[data-vsbox-content]');
10
+ expect(el).toBeInTheDocument();
11
+ expect(el).toHaveAttribute('data-vsbox-content', 'true');
12
+ });
13
+ it('renders children correctly', () => {
14
+ render(_jsx(VSBoxContent, { children: _jsx("span", { id: "child", children: "content" }) }));
15
+ expect(screen.getByTestId('child')).toBeInTheDocument();
16
+ });
17
+ it('passes additional MUI Box props through', () => {
18
+ const { container } = render(_jsx(VSBoxContent, { sx: { display: 'flex' }, children: _jsx("span", { children: "content" }) }));
19
+ expect(container.firstChild).toBeInTheDocument();
20
+ });
21
+ it('renders multiple children', () => {
22
+ render(_jsxs(VSBoxContent, { children: [_jsx("span", { id: "a", children: "A" }), _jsx("span", { id: "b", children: "B" })] }));
23
+ expect(screen.getByTestId('a')).toBeInTheDocument();
24
+ expect(screen.getByTestId('b')).toBeInTheDocument();
25
+ });
26
+ });
@@ -0,0 +1,55 @@
1
+ /// <reference types="vitest" />
2
+ import { describe, expect, it } from 'vitest';
3
+ import WordLexer from './word/WordLexer';
4
+ describe('WordLexer', () => {
5
+ const lexer = new WordLexer();
6
+ describe('parse', () => {
7
+ it('parses a single word', () => {
8
+ const result = lexer.parse('hello');
9
+ expect(result.tokens).toHaveLength(2); // word + eop
10
+ expect(result.tokens[0].type).toBe('word');
11
+ expect(result.tokens[0].value).toBe('hello');
12
+ });
13
+ it('parses two words separated by a space', () => {
14
+ const result = lexer.parse('hello world');
15
+ const nonEop = result.tokens.filter(t => t.type !== 'eop');
16
+ expect(nonEop.some(t => t.type === 'word' && t.value === 'hello')).toBe(true);
17
+ expect(nonEop.some(t => t.type === 'word' && t.value === 'world')).toBe(true);
18
+ });
19
+ it('always ends with an eop token', () => {
20
+ const result = lexer.parse('foo bar');
21
+ const last = result.tokens[result.tokens.length - 1];
22
+ expect(last.type).toBe('eop');
23
+ expect(last.value).toBe('');
24
+ });
25
+ it('returns an eop-only token list for empty input', () => {
26
+ const result = lexer.parse('');
27
+ expect(result.tokens).toHaveLength(1);
28
+ expect(result.tokens[0].type).toBe('eop');
29
+ });
30
+ it('sets cursor correctly', () => {
31
+ const result = lexer.parse('hello', 3);
32
+ expect(result.cursor).toBe(3);
33
+ });
34
+ it('sets current token for cursor in the middle of a word', () => {
35
+ const result = lexer.parse('hello', 3);
36
+ expect(result.current?.type).toBe('word');
37
+ });
38
+ it('provides suggest token for current cursor position', () => {
39
+ const result = lexer.parse('hello world', 7);
40
+ expect(result.suggest).toBeDefined();
41
+ expect(result.suggest.token).toBeDefined();
42
+ });
43
+ it('sets startIndex/endIndex correctly on word tokens', () => {
44
+ const result = lexer.parse('hi');
45
+ const wordToken = result.tokens.find(t => t.type === 'word');
46
+ expect(wordToken.startIndex).toBe(0);
47
+ expect(wordToken.endIndex).toBe(1);
48
+ });
49
+ it('whitespace tokens have correct type', () => {
50
+ const result = lexer.parse('a b');
51
+ const wsToken = result.tokens.find(t => t.type === 'whitespace');
52
+ expect(wsToken).toBeDefined();
53
+ });
54
+ });
55
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ /// <reference types="vitest" />
2
+ import { renderHook } from '@testing-library/react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import useMyTheme from './useMyTheme';
5
+ describe('useMyTheme', () => {
6
+ it('returns a theme configuration object', () => {
7
+ const { result } = renderHook(() => useMyTheme());
8
+ expect(result.current).toBeDefined();
9
+ expect(typeof result.current).toBe('object');
10
+ });
11
+ it('contains a dark palette entry', () => {
12
+ const { result } = renderHook(() => useMyTheme());
13
+ expect(result.current.palette?.dark).toBeDefined();
14
+ });
15
+ it('contains a light palette entry', () => {
16
+ const { result } = renderHook(() => useMyTheme());
17
+ expect(result.current.palette?.light).toBeDefined();
18
+ });
19
+ it('returns the same reference on re-render (stable return value)', () => {
20
+ const { result, rerender } = renderHook(() => useMyTheme());
21
+ const first = result.current;
22
+ rerender();
23
+ expect(result.current).toBe(first);
24
+ });
25
+ it('dark palette includes primary and secondary colours', () => {
26
+ const { result } = renderHook(() => useMyTheme());
27
+ expect(result.current.palette.dark.primary).toHaveProperty('main');
28
+ expect(result.current.palette.dark.secondary).toHaveProperty('main');
29
+ });
30
+ it('light palette includes primary and secondary colours', () => {
31
+ const { result } = renderHook(() => useMyTheme());
32
+ expect(result.current.palette.light.primary).toHaveProperty('main');
33
+ expect(result.current.palette.light.secondary).toHaveProperty('main');
34
+ });
35
+ });
@@ -0,0 +1,34 @@
1
+ /// <reference types="vitest" />
2
+ import { describe, expect, it } from 'vitest';
3
+ import TOKEN_PROVIDER from './eqlTokenProvider';
4
+ describe('EQL token provider', () => {
5
+ it('exports a non-null object', () => {
6
+ expect(TOKEN_PROVIDER).toBeDefined();
7
+ expect(TOKEN_PROVIDER).not.toBeNull();
8
+ expect(typeof TOKEN_PROVIDER).toBe('object');
9
+ });
10
+ it('has a "root" tokenizer entry', () => {
11
+ expect(TOKEN_PROVIDER.tokenizer).toBeDefined();
12
+ expect(TOKEN_PROVIDER.tokenizer.root).toBeDefined();
13
+ expect(Array.isArray(TOKEN_PROVIDER.tokenizer.root)).toBe(true);
14
+ });
15
+ it('uses "invalid" as the defaultToken', () => {
16
+ expect(TOKEN_PROVIDER.defaultToken).toBe('invalid');
17
+ });
18
+ it('defines the expected EQL keywords', () => {
19
+ const kws = TOKEN_PROVIDER.keywords;
20
+ expect(kws).toContain('where');
21
+ expect(kws).toContain('not');
22
+ expect(kws).toContain('in');
23
+ expect(kws).toContain('head');
24
+ expect(kws).toContain('tail');
25
+ });
26
+ it('defines boolean operators', () => {
27
+ const booleans = TOKEN_PROVIDER.booleans;
28
+ expect(booleans).toContain('and');
29
+ expect(booleans).toContain('or');
30
+ });
31
+ it('has includeLF set to true', () => {
32
+ expect(TOKEN_PROVIDER.includeLF).toBe(true);
33
+ });
34
+ });
@@ -0,0 +1,34 @@
1
+ /// <reference types="vitest" />
2
+ import { describe, expect, it } from 'vitest';
3
+ import TOKEN_PROVIDER from './luceneTokenProvider';
4
+ describe('Lucene token provider', () => {
5
+ it('exports a non-null object', () => {
6
+ expect(TOKEN_PROVIDER).toBeDefined();
7
+ expect(TOKEN_PROVIDER).not.toBeNull();
8
+ expect(typeof TOKEN_PROVIDER).toBe('object');
9
+ });
10
+ it('has a "root" tokenizer entry', () => {
11
+ expect(TOKEN_PROVIDER.tokenizer).toBeDefined();
12
+ expect(TOKEN_PROVIDER.tokenizer.root).toBeDefined();
13
+ expect(Array.isArray(TOKEN_PROVIDER.tokenizer.root)).toBe(true);
14
+ });
15
+ it('defines the expected operators', () => {
16
+ const ops = TOKEN_PROVIDER.operators;
17
+ expect(ops).toContain('-');
18
+ expect(ops).toContain('&&');
19
+ expect(ops).toContain('||');
20
+ expect(ops).toContain(':');
21
+ });
22
+ it('defines the expected keywords (AND, OR, NOT)', () => {
23
+ const kws = TOKEN_PROVIDER.keywords;
24
+ expect(kws).toContain('AND');
25
+ expect(kws).toContain('OR');
26
+ expect(kws).toContain('NOT');
27
+ });
28
+ it('uses "default" as the defaultToken', () => {
29
+ expect(TOKEN_PROVIDER.defaultToken).toBe('default');
30
+ });
31
+ it('includes a string tokenizer state for quoted values', () => {
32
+ expect(TOKEN_PROVIDER.tokenizer).toHaveProperty('string');
33
+ });
34
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import i18n from '@cccsaurora/howler-ui/i18n';
6
+ import { I18nextProvider } from 'react-i18next';
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import HomeSettings from './HomeSettings';
9
+ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: children }));
10
+ describe('HomeSettings', () => {
11
+ let onRefreshRateChange;
12
+ let onEdit;
13
+ beforeEach(() => {
14
+ onRefreshRateChange = vi.fn();
15
+ onEdit = vi.fn();
16
+ vi.clearAllMocks();
17
+ });
18
+ it('renders the settings icon button', () => {
19
+ render(_jsx(HomeSettings, { isEditing: false, refreshRate: 30, onRefreshRateChange: onRefreshRateChange, onEdit: onEdit }), { wrapper: Wrapper });
20
+ expect(screen.getByRole('button')).toBeInTheDocument();
21
+ });
22
+ it('opens the settings menu when the icon button is clicked', async () => {
23
+ const user = userEvent.setup();
24
+ render(_jsx(HomeSettings, { isEditing: false, refreshRate: 30, onRefreshRateChange: onRefreshRateChange, onEdit: onEdit }), { wrapper: Wrapper });
25
+ await user.click(screen.getByRole('button'));
26
+ // The menu should now be open – the edit menu item should be visible
27
+ expect(screen.getByRole('menu')).toBeInTheDocument();
28
+ });
29
+ it('calls onEdit when the Edit menu item is clicked', async () => {
30
+ const user = userEvent.setup();
31
+ render(_jsx(HomeSettings, { isEditing: false, refreshRate: 30, onRefreshRateChange: onRefreshRateChange, onEdit: onEdit }), { wrapper: Wrapper });
32
+ await user.click(screen.getByRole('button'));
33
+ const editItem = screen.getAllByRole('menuitem')[0];
34
+ await user.click(editItem);
35
+ expect(onEdit).toHaveBeenCalledTimes(1);
36
+ });
37
+ it('disables the Edit menu item when isEditing=true', async () => {
38
+ const user = userEvent.setup();
39
+ render(_jsx(HomeSettings, { isEditing: true, refreshRate: 30, onRefreshRateChange: onRefreshRateChange, onEdit: onEdit }), { wrapper: Wrapper });
40
+ await user.click(screen.getByRole('button'));
41
+ const editItem = screen.getAllByRole('menuitem')[0];
42
+ expect(editItem).toHaveAttribute('aria-disabled', 'true');
43
+ });
44
+ });
@@ -0,0 +1,50 @@
1
+ /// <reference types="vitest" />
2
+ import { describe, expect, it } from 'vitest';
3
+ import { conf, language } from './markdownExtendedTokenProvider';
4
+ describe('markdownExtendedTokenProvider', () => {
5
+ describe('conf', () => {
6
+ it('exports a language configuration object', () => {
7
+ expect(conf).toBeDefined();
8
+ expect(typeof conf).toBe('object');
9
+ });
10
+ it('defines block comment markers', () => {
11
+ expect(conf.comments?.blockComment).toEqual(['<!--', '-->']);
12
+ });
13
+ it('defines bracket pairs', () => {
14
+ expect(Array.isArray(conf.brackets)).toBe(true);
15
+ expect(conf.brackets.length).toBeGreaterThan(0);
16
+ });
17
+ it('defines auto-closing pairs', () => {
18
+ expect(Array.isArray(conf.autoClosingPairs)).toBe(true);
19
+ });
20
+ it('defines folding markers', () => {
21
+ expect(conf.folding?.markers?.start).toBeInstanceOf(RegExp);
22
+ expect(conf.folding?.markers?.end).toBeInstanceOf(RegExp);
23
+ });
24
+ });
25
+ describe('language', () => {
26
+ it('exports a monarch language definition', () => {
27
+ expect(language).toBeDefined();
28
+ expect(typeof language).toBe('object');
29
+ });
30
+ it('has a root tokenizer state', () => {
31
+ expect(language.tokenizer).toBeDefined();
32
+ expect(Array.isArray(language.tokenizer.root)).toBe(true);
33
+ });
34
+ it('uses an empty string as the default token', () => {
35
+ expect(language.defaultToken).toBe('');
36
+ });
37
+ it('includes Handlebars operators in the language definition', () => {
38
+ const operators = language.handlebars_operators;
39
+ expect(Array.isArray(operators)).toBe(true);
40
+ expect(operators).toContain('#if');
41
+ expect(operators).toContain('/if');
42
+ });
43
+ it('includes a handlebars tokenizer state', () => {
44
+ expect(language.tokenizer).toHaveProperty('handlebars');
45
+ });
46
+ it('includes an html tokenizer state', () => {
47
+ expect(language.tokenizer).toHaveProperty('html');
48
+ });
49
+ });
50
+ });
package/package.json CHANGED
@@ -21,20 +21,20 @@
21
21
  "@iconify/icons-logos": "^1.2.36",
22
22
  "@iconify/icons-simple-icons": "^1.2.74",
23
23
  "@iconify/react": "^4.1.1",
24
- "@jsonforms/core": "^3.7.0",
25
- "@jsonforms/material-renderers": "^3.7.0",
26
- "@jsonforms/react": "^3.7.0",
27
- "@microlink/react-json-view": "^1.31.20",
24
+ "@jsonforms/core": "^3.8.0",
25
+ "@jsonforms/material-renderers": "^3.8.0",
26
+ "@jsonforms/react": "^3.8.0",
27
+ "@microlink/react-json-view": "^1.31.22",
28
28
  "@monaco-editor/react": "^4.7.0",
29
29
  "ajv": "^8.20.0",
30
30
  "ajv-i18n": "^4.2.0",
31
- "axios": "^1.17.0",
31
+ "axios": "^1.18.1",
32
32
  "axios-retry": "^3.9.1",
33
33
  "chart.js": "^4.5.1",
34
34
  "chartjs-adapter-dayjs-4": "^1.0.4",
35
35
  "chartjs-plugin-zoom": "^2.2.0",
36
36
  "dayjs": "^1.11.21",
37
- "dompurify": "^3.4.9",
37
+ "dompurify": "^3.4.11",
38
38
  "flat": "^6.0.1",
39
39
  "fuse.js": "^7.4.2",
40
40
  "handlebars": "^4.7.9",
@@ -44,7 +44,7 @@
44
44
  "json-schema": "^0.4.0",
45
45
  "lodash-es": "^4.18.1",
46
46
  "md5": "^2.3.0",
47
- "mermaid": "^11.15.0",
47
+ "mermaid": "^11.16.0",
48
48
  "monaco-editor": "0.49.0",
49
49
  "notistack": "^3.0.2",
50
50
  "react": "^18.3.1",
@@ -67,7 +67,7 @@
67
67
  "unist-util-visit": "^5.1.0",
68
68
  "url-join": "^5.0.0",
69
69
  "use-context-selector": "^2.0.0",
70
- "uuid": "^14.0.0",
70
+ "uuid": "^14.0.1",
71
71
  "web-vitals": "^4.2.4"
72
72
  },
73
73
  "jest": {
@@ -93,7 +93,7 @@
93
93
  "url": "https://github.com/CybercentreCanada/howler"
94
94
  },
95
95
  "type": "module",
96
- "version": "2.19.0-dev.1143",
96
+ "version": "2.19.0-dev.1151",
97
97
  "exports": {
98
98
  "./i18n": "./i18n.js",
99
99
  "./index.css": "./index.css",
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,184 @@
1
+ /// <reference types="vitest" />
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import AxiosClient from './AxiosClient';
4
+ // ---------------------------------------------------------------------------
5
+ // vi.hoisted() ensures these values are available when the vi.mock factories
6
+ // run (which happens before any module code executes).
7
+ // ---------------------------------------------------------------------------
8
+ const { mockAxiosInstance, mockAxiosError, mockInterceptorResponseUse, interceptorRef } = vi.hoisted(() => {
9
+ // Ref object used to capture the response interceptor across test resets
10
+ const _interceptorRef = { fn: null };
11
+ const _mockInterceptorResponseUse = vi.fn().mockImplementation((onFulfilled) => {
12
+ _interceptorRef.fn = onFulfilled;
13
+ });
14
+ const _mockAxiosInstance = vi.fn();
15
+ _mockAxiosInstance.interceptors = {
16
+ response: { use: _mockInterceptorResponseUse }
17
+ };
18
+ class MockAxiosError extends Error {
19
+ response;
20
+ constructor(message, response) {
21
+ super(message);
22
+ this.name = 'AxiosError';
23
+ this.response = response;
24
+ }
25
+ }
26
+ return {
27
+ mockAxiosInstance: _mockAxiosInstance,
28
+ mockAxiosError: MockAxiosError,
29
+ mockInterceptorResponseUse: _mockInterceptorResponseUse,
30
+ interceptorRef: _interceptorRef
31
+ };
32
+ });
33
+ vi.mock('axios', () => ({
34
+ default: { create: vi.fn(() => mockAxiosInstance) },
35
+ AxiosError: mockAxiosError
36
+ }));
37
+ vi.mock('axios-retry', () => ({
38
+ default: vi.fn(),
39
+ exponentialDelay: vi.fn(),
40
+ isNetworkError: vi.fn()
41
+ }));
42
+ vi.mock('utils/sessionStorage', () => ({
43
+ getAxiosCache: vi.fn(() => ({})),
44
+ setAxiosCache: vi.fn()
45
+ }));
46
+ // ---------------------------------------------------------------------------
47
+ // Tests
48
+ // ---------------------------------------------------------------------------
49
+ describe('AxiosClient', () => {
50
+ let client;
51
+ beforeEach(() => {
52
+ interceptorRef.fn = null;
53
+ client = new AxiosClient();
54
+ });
55
+ afterEach(() => {
56
+ vi.clearAllMocks();
57
+ });
58
+ // -------------------------------------------------------------------------
59
+ // fetch() – happy paths
60
+ // -------------------------------------------------------------------------
61
+ describe('fetch() – successful responses', () => {
62
+ it('returns [data, status, headers] on a 200 response', async () => {
63
+ const payload = { api_response: { items: [] } };
64
+ mockAxiosInstance.mockResolvedValueOnce({
65
+ data: payload,
66
+ status: 200,
67
+ headers: { 'content-type': 'application/json' }
68
+ });
69
+ const result = await client.fetch('/api/v1/hit');
70
+ expect(result[0]).toEqual(payload);
71
+ expect(result[1]).toBe(200);
72
+ });
73
+ it('uses GET as the default HTTP method', async () => {
74
+ mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
75
+ await client.fetch('/api/v1/hit');
76
+ const config = mockAxiosInstance.mock.calls[0][0];
77
+ expect(config.method).toBe('get');
78
+ });
79
+ it('passes POST as the HTTP method when specified', async () => {
80
+ mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 201, headers: {} });
81
+ await client.fetch('/api/v1/hit', 'post', { key: 'val' });
82
+ const config = mockAxiosInstance.mock.calls[0][0];
83
+ expect(config.method).toBe('post');
84
+ });
85
+ it('serialises the body as a JSON string', async () => {
86
+ mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
87
+ await client.fetch('/api/v1/hit', 'post', { status: 'open' });
88
+ const config = mockAxiosInstance.mock.calls[0][0];
89
+ expect(config.data).toBe(JSON.stringify({ status: 'open' }));
90
+ });
91
+ it('passes URLSearchParams through as params', async () => {
92
+ mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
93
+ const params = new URLSearchParams({ q: 'test' });
94
+ await client.fetch('/api/v1/hit', 'get', undefined, params);
95
+ const config = mockAxiosInstance.mock.calls[0][0];
96
+ expect(config.params).toBe(params);
97
+ });
98
+ it('forwards custom headers', async () => {
99
+ mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
100
+ const headers = { Authorization: '******' };
101
+ await client.fetch('/api/v1/hit', 'get', undefined, undefined, headers);
102
+ const config = mockAxiosInstance.mock.calls[0][0];
103
+ expect(config.headers).toEqual(headers);
104
+ });
105
+ it('sets withCredentials to true', async () => {
106
+ mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
107
+ await client.fetch('/api/v1/hit');
108
+ const config = mockAxiosInstance.mock.calls[0][0];
109
+ expect(config.withCredentials).toBe(true);
110
+ });
111
+ it('passes the URL through to the axios config', async () => {
112
+ mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
113
+ await client.fetch('/api/v1/test-url');
114
+ const config = mockAxiosInstance.mock.calls[0][0];
115
+ expect(config.url).toBe('/api/v1/test-url');
116
+ });
117
+ });
118
+ // -------------------------------------------------------------------------
119
+ // fetch() – error handling
120
+ // -------------------------------------------------------------------------
121
+ describe('fetch() – error handling', () => {
122
+ it('returns [data, status, headers] when the error is an AxiosError with a response', async () => {
123
+ const errorResponse = { data: { error: 'not found' }, status: 404, headers: {} };
124
+ const axiosErr = new mockAxiosError('Not Found', errorResponse);
125
+ mockAxiosInstance.mockRejectedValueOnce(axiosErr);
126
+ const result = await client.fetch('/api/v1/missing');
127
+ expect(result[0]).toEqual({ error: 'not found' });
128
+ expect(result[1]).toBe(404);
129
+ });
130
+ it('rethrows non-AxiosError exceptions', async () => {
131
+ mockAxiosInstance.mockRejectedValueOnce(new TypeError('network failure'));
132
+ await expect(client.fetch('/api/v1/hit')).rejects.toThrow('network failure');
133
+ });
134
+ it('rethrows AxiosError that has no response body', async () => {
135
+ // AxiosError without a .response property → condition `e.response?.data` is falsy → rethrow
136
+ const axiosErr = new mockAxiosError('timeout');
137
+ mockAxiosInstance.mockRejectedValueOnce(axiosErr);
138
+ await expect(client.fetch('/api/v1/hit')).rejects.toThrow('timeout');
139
+ });
140
+ });
141
+ // -------------------------------------------------------------------------
142
+ // AxiosCache – response interceptor
143
+ // -------------------------------------------------------------------------
144
+ describe('AxiosCache response interceptor', () => {
145
+ it('is registered on the axios instance during construction', () => {
146
+ expect(mockInterceptorResponseUse).toHaveBeenCalled();
147
+ expect(interceptorRef.fn).toBeTypeOf('function');
148
+ });
149
+ it('stores a new etag cache entry on a 2xx response with an etag header', async () => {
150
+ const { setAxiosCache } = await import('utils/sessionStorage');
151
+ const res = {
152
+ status: 200,
153
+ headers: { etag: '"abc123"' },
154
+ data: { value: 1 },
155
+ config: { headers: {} }
156
+ };
157
+ await interceptorRef.fn(res);
158
+ expect(setAxiosCache).toHaveBeenCalledWith('"abc123"', { value: 1 });
159
+ });
160
+ it('replaces data from cache on a 304 response', async () => {
161
+ const { getAxiosCache } = await import('utils/sessionStorage');
162
+ getAxiosCache.mockReturnValueOnce({ '"etag-v1"': { cached: true } });
163
+ const res = {
164
+ status: 304,
165
+ headers: {},
166
+ data: null,
167
+ config: { headers: { 'If-Match': '"etag-v1"' } }
168
+ };
169
+ const result = await interceptorRef.fn(res);
170
+ expect(result.data).toEqual({ cached: true });
171
+ });
172
+ it('passes through a 2xx response without an etag without caching', async () => {
173
+ const { setAxiosCache } = await import('utils/sessionStorage');
174
+ const res = {
175
+ status: 200,
176
+ headers: {},
177
+ data: { ok: true },
178
+ config: { headers: {} }
179
+ };
180
+ await interceptorRef.fn(res);
181
+ expect(setAxiosCache).not.toHaveBeenCalled();
182
+ });
183
+ });
184
+ });
@@ -1,6 +1,6 @@
1
1
  /// <reference types="vitest" />
2
- import { describe, expect, it } from 'vitest';
3
- import { bytesToSize, compareTimestamp, convertCustomDateRangeToLucene, convertDateToLucene, convertLuceneToDate, flattenDeep, formatDate, getTimeRange, hashCode, humanReadableNumber, removeEmpty, searchObject, sortByTimestamp, tryParse, twitterShort } from './utils';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { bytesToSize, compareTimestamp, convertCustomDateRangeToLucene, convertDateToLucene, convertLuceneToDate, delay, flattenDeep, formatDate, getProvider, getTimeRange, hashCode, humanReadableNumber, removeEmpty, searchObject, searchResultsDisplay, sortByTimestamp, stringToColor, tryParse, twitterShort } from './utils';
4
4
  describe('bytesToSize', () => {
5
5
  it('returns "0 B" for 0', () => {
6
6
  expect(bytesToSize(0)).toBe('0 B');
@@ -290,3 +290,120 @@ describe('twitterShort', () => {
290
290
  expect(result).toMatch(/year/);
291
291
  });
292
292
  });
293
+ describe('stringToColor', () => {
294
+ it('returns a non-empty string for any input', () => {
295
+ const result = stringToColor('hello');
296
+ expect(typeof result).toBe('string');
297
+ expect(result.length).toBeGreaterThan(0);
298
+ });
299
+ it('returns the same color for the same input', () => {
300
+ expect(stringToColor('alice')).toBe(stringToColor('alice'));
301
+ });
302
+ it('returns different colors for different inputs', () => {
303
+ // Not guaranteed for all pairs, but statistically very likely for distinct words
304
+ const colors = ['alice', 'bob', 'carol', 'dave'].map(stringToColor);
305
+ const unique = new Set(colors);
306
+ expect(unique.size).toBeGreaterThan(1);
307
+ });
308
+ it('handles an empty string without throwing', () => {
309
+ expect(() => stringToColor('')).not.toThrow();
310
+ });
311
+ });
312
+ describe('delay', () => {
313
+ it('resolves after the specified time', async () => {
314
+ vi.useFakeTimers();
315
+ const promise = delay(100);
316
+ vi.advanceTimersByTime(100);
317
+ await expect(promise).resolves.toBeUndefined();
318
+ vi.useRealTimers();
319
+ });
320
+ it('does not resolve before the specified time', async () => {
321
+ vi.useFakeTimers();
322
+ let resolved = false;
323
+ delay(200).then(() => {
324
+ resolved = true;
325
+ });
326
+ vi.advanceTimersByTime(100);
327
+ expect(resolved).toBe(false);
328
+ vi.advanceTimersByTime(100);
329
+ await Promise.resolve(); // flush microtasks
330
+ expect(resolved).toBe(true);
331
+ vi.useRealTimers();
332
+ });
333
+ it('can be cancelled via the .cancel() method without rejecting by default', () => {
334
+ vi.useFakeTimers();
335
+ const d = delay(100);
336
+ expect(() => d.cancel()).not.toThrow();
337
+ vi.useRealTimers();
338
+ });
339
+ it('rejects on cancel when rejectOnCancel=true', async () => {
340
+ vi.useFakeTimers();
341
+ const d = delay(100, true);
342
+ const rejection = expect(d).rejects.toBeUndefined();
343
+ d.cancel();
344
+ await rejection;
345
+ vi.useRealTimers();
346
+ });
347
+ });
348
+ describe('getProvider', () => {
349
+ const originalLocation = window.location;
350
+ afterEach(() => {
351
+ Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
352
+ });
353
+ it('returns the provider from the search params when not in oauth path', () => {
354
+ Object.defineProperty(window, 'location', {
355
+ writable: true,
356
+ value: { pathname: '/login', search: '?provider=azure', href: 'http://localhost/login?provider=azure' }
357
+ });
358
+ expect(getProvider()).toBe('azure');
359
+ });
360
+ it('returns null when no provider param is present and path is not oauth', () => {
361
+ Object.defineProperty(window, 'location', {
362
+ writable: true,
363
+ value: { pathname: '/hits', search: '', href: 'http://localhost/hits' }
364
+ });
365
+ expect(getProvider()).toBeNull();
366
+ });
367
+ });
368
+ describe('searchResultsDisplay', () => {
369
+ const originalLocation = window.location;
370
+ afterEach(() => {
371
+ Object.defineProperty(window, 'location', { value: originalLocation, writable: true });
372
+ });
373
+ it('returns count as string when below the max', () => {
374
+ Object.defineProperty(window, 'location', {
375
+ writable: true,
376
+ value: { pathname: '/', search: '', href: 'http://localhost/' }
377
+ });
378
+ expect(searchResultsDisplay(500)).toBe('500');
379
+ });
380
+ it('appends "+" when count equals the default max (10000) and no track_total_hits param', () => {
381
+ Object.defineProperty(window, 'location', {
382
+ writable: true,
383
+ value: { pathname: '/', search: '', href: 'http://localhost/' }
384
+ });
385
+ expect(searchResultsDisplay(10000)).toBe('10000+');
386
+ });
387
+ it('appends "+" when count matches the explicit track_total_hits param', () => {
388
+ Object.defineProperty(window, 'location', {
389
+ writable: true,
390
+ value: { pathname: '/', search: '?track_total_hits=500', href: 'http://localhost/?track_total_hits=500' }
391
+ });
392
+ expect(searchResultsDisplay(500)).toBe('500+');
393
+ });
394
+ it('does not append "+" when count does not equal track_total_hits', () => {
395
+ Object.defineProperty(window, 'location', {
396
+ writable: true,
397
+ value: { pathname: '/', search: '?track_total_hits=1000', href: 'http://localhost/?track_total_hits=1000' }
398
+ });
399
+ expect(searchResultsDisplay(500)).toBe('500');
400
+ });
401
+ it('uses a custom max when provided', () => {
402
+ Object.defineProperty(window, 'location', {
403
+ writable: true,
404
+ value: { pathname: '/', search: '', href: 'http://localhost/' }
405
+ });
406
+ expect(searchResultsDisplay(500, 500)).toBe('500+');
407
+ expect(searchResultsDisplay(499, 500)).toBe('499');
408
+ });
409
+ });