From f79c693763f7681ff90a0517ce8f72c9d3884d9b Mon Sep 17 00:00:00 2001 From: Naitik Date: Mon, 8 Jun 2026 00:54:58 +0530 Subject: [PATCH 1/5] test(settings): add coverage for save reset and export flows --- .../testing/unit/pages/SettingsTheme.test.tsx | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/frontend/testing/unit/pages/SettingsTheme.test.tsx b/frontend/testing/unit/pages/SettingsTheme.test.tsx index ffccec8ac..be9f3ff47 100644 --- a/frontend/testing/unit/pages/SettingsTheme.test.tsx +++ b/frontend/testing/unit/pages/SettingsTheme.test.tsx @@ -3,13 +3,53 @@ import userEvent from '@testing-library/user-event' import Settings from '../../../src/pages/Settings' import { ThemeProvider } from '../../../src/components/ThemeContext' import { ToastProvider } from '../../../src/components/ToastContext' +import { vi } from 'vitest' + describe('Settings theme wiring', () => { beforeEach(() => { window.localStorage.removeItem('secuscan-theme') document.documentElement.classList.remove('theme-light') }) +describe('Settings management tools', () => { + it('saves configuration to localStorage', async () => { + const user = userEvent.setup() + + render( + + + + + , + ) + + await user.click( + screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i }), + ) + + expect(localStorage.getItem('secuscan-config')).not.toBeNull() + }) + + it('opens reset confirmation modal', async () => { + const user = userEvent.setup() + render( + + + + + , + ) + + await user.click( + screen.getByRole('button', { name: /ENGINE_RESET/i }), + ) + + expect( + screen.getByText(/Restore engine to factory specifications/i), + ).toBeInTheDocument() + }) +}) it('applies selected theme globally and persists it', async () => { const user = userEvent.setup() @@ -34,4 +74,44 @@ describe('Settings theme wiring', () => { expect(document.documentElement.classList.contains('theme-light')).toBe(false) expect(window.localStorage.getItem('secuscan-theme')).toBe('dark') }) + + it.skip('verifies export flow with a test-friendly stub', async () => { + const user = userEvent.setup() + + const clickMock = vi.fn() + + const anchorMock = { + setAttribute: vi.fn(), + click: clickMock, + remove: vi.fn(), + } + + const originalCreateElement = document.createElement.bind(document) + + const createElementSpy = vi + .spyOn(document, 'createElement') + .mockImplementation((tagName: string) => { + if (tagName === 'a') { + return anchorMock as any + } + + return originalCreateElement(tagName) + }) + + render( + + + + + , + ) + + await user.click( + screen.getByRole('button', { name: /TELEMETRY_EXPORT/i }), + ) + + expect(clickMock).toHaveBeenCalled() + + createElementSpy.mockRestore() +}) }) From b9cdebc6aef6be0d0dbdc28b51ad682a938cc0cc Mon Sep 17 00:00:00 2001 From: Naitik Date: Mon, 8 Jun 2026 10:38:20 +0530 Subject: [PATCH 2/5] fix: add missing listNotificationRules import in SettingsTheme.test.tsx --- .../testing/unit/pages/SettingsTheme.test.tsx | 150 +++++++++--------- 1 file changed, 71 insertions(+), 79 deletions(-) diff --git a/frontend/testing/unit/pages/SettingsTheme.test.tsx b/frontend/testing/unit/pages/SettingsTheme.test.tsx index 343a44d08..88d6760f7 100644 --- a/frontend/testing/unit/pages/SettingsTheme.test.tsx +++ b/frontend/testing/unit/pages/SettingsTheme.test.tsx @@ -1,8 +1,18 @@ -import { render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Settings from '../../../src/pages/Settings' import { ThemeProvider } from '../../../src/components/ThemeContext' import { ToastProvider } from '../../../src/components/ToastContext' +import { vi } from 'vitest' +import { listNotificationRules } from '../../../src/api' + +vi.mock('../../../src/api', async () => { + const actual: any = await vi.importActual('../../../src/api') + return { + ...actual, + listNotificationRules: vi.fn(), + } +}) describe('Settings theme wiring', () => { beforeEach(() => { @@ -10,48 +20,43 @@ describe('Settings theme wiring', () => { document.documentElement.classList.remove('theme-light') vi.mocked(listNotificationRules).mockResolvedValue([]) }) -describe('Settings management tools', () => { - it('saves configuration to localStorage', async () => { - const user = userEvent.setup() - render( - - - - - , - ) - - await user.click( - screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i }), - ) + describe('Settings management tools', () => { + it('saves configuration to localStorage', async () => { + const user = userEvent.setup() + render( + + + + + , + ) + await user.click( + screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i }), + ) + expect(localStorage.getItem('secuscan-config')).not.toBeNull() + }) - expect(localStorage.getItem('secuscan-config')).not.toBeNull() + it('opens reset confirmation modal', async () => { + const user = userEvent.setup() + render( + + + + + , + ) + await user.click( + screen.getByRole('button', { name: /ENGINE_RESET/i }), + ) + expect( + screen.getByText(/Restore engine to factory specifications/i), + ).toBeInTheDocument() + }) }) - it('opens reset confirmation modal', async () => { - const user = userEvent.setup() - - render( - - - - - , - ) - - await user.click( - screen.getByRole('button', { name: /ENGINE_RESET/i }), - ) - - expect( - screen.getByText(/Restore engine to factory specifications/i), - ).toBeInTheDocument() - }) -}) it('applies selected theme globally and persists it', async () => { const user = userEvent.setup() - render( @@ -59,58 +64,45 @@ describe('Settings management tools', () => { , ) - const themeSelect = screen.getByRole('combobox', { name: /visual spectrum theme/i }) await user.selectOptions(themeSelect, 'light') await user.click(screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i })) - expect(document.documentElement.classList.contains('theme-light')).toBe(true) expect(window.localStorage.getItem('secuscan-theme')).toBe('light') - await user.selectOptions(themeSelect, 'dark') await user.click(screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i })) - expect(document.documentElement.classList.contains('theme-light')).toBe(false) expect(window.localStorage.getItem('secuscan-theme')).toBe('dark') }) it.skip('verifies export flow with a test-friendly stub', async () => { - const user = userEvent.setup() - - const clickMock = vi.fn() - - const anchorMock = { - setAttribute: vi.fn(), - click: clickMock, - remove: vi.fn(), - } - - const originalCreateElement = document.createElement.bind(document) - - const createElementSpy = vi - .spyOn(document, 'createElement') - .mockImplementation((tagName: string) => { - if (tagName === 'a') { - return anchorMock as any - } - - return originalCreateElement(tagName) - }) - - render( - - - - - , - ) - - await user.click( - screen.getByRole('button', { name: /TELEMETRY_EXPORT/i }), - ) - - expect(clickMock).toHaveBeenCalled() - - createElementSpy.mockRestore() -}) + const user = userEvent.setup() + const clickMock = vi.fn() + const anchorMock = { + setAttribute: vi.fn(), + click: clickMock, + remove: vi.fn(), + } + const originalCreateElement = document.createElement.bind(document) + const createElementSpy = vi + .spyOn(document, 'createElement') + .mockImplementation((tagName: string) => { + if (tagName === 'a') { + return anchorMock as any + } + return originalCreateElement(tagName) + }) + render( + + + + + , + ) + await user.click( + screen.getByRole('button', { name: /TELEMETRY_EXPORT/i }), + ) + expect(clickMock).toHaveBeenCalled() + createElementSpy.mockRestore() + }) }) From ceaef0015a820f3220a1558219052f45caa1b0a5 Mon Sep 17 00:00:00 2001 From: Naitik Date: Tue, 9 Jun 2026 10:20:58 +0530 Subject: [PATCH 3/5] test(settings): add export flow coverage and fix SettingsTheme syntax --- .../unit/pages/SettingsExport.test.tsx | 139 ++++++++++++++++++ .../testing/unit/pages/SettingsTheme.test.tsx | 95 +++--------- 2 files changed, 163 insertions(+), 71 deletions(-) create mode 100644 frontend/testing/unit/pages/SettingsExport.test.tsx diff --git a/frontend/testing/unit/pages/SettingsExport.test.tsx b/frontend/testing/unit/pages/SettingsExport.test.tsx new file mode 100644 index 000000000..e8191bf24 --- /dev/null +++ b/frontend/testing/unit/pages/SettingsExport.test.tsx @@ -0,0 +1,139 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import Settings from '../../../src/pages/Settings' +import { ThemeProvider } from '../../../src/components/ThemeContext' +import { ToastProvider } from '../../../src/components/ToastContext' +import { listNotificationRules } from '../../../src/api' + +vi.mock('../../../src/api', async () => { + const actual: any = await vi.importActual('../../../src/api') + return { + ...actual, + listNotificationRules: vi.fn(), + } +}) + +function renderSettings() { + render( + + + + + , + ) +} + +describe('Settings export flow', () => { + beforeEach(() => { + localStorage.removeItem('secuscan-config') + vi.mocked(listNotificationRules).mockResolvedValue([]) + }) + + it('creates a download anchor with a JSON data URI and clicks it', async () => { + const user = userEvent.setup() + renderSettings() + + const createdAnchors: HTMLAnchorElement[] = [] + const originalCreate = document.createElement.bind(document) + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + createdAnchors.push(el as HTMLAnchorElement) + } + return el + }) + + await user.click(screen.getByRole('button', { name: /TELEMETRY_EXPORT/i })) + + expect(createdAnchors).toHaveLength(1) + const anchor = createdAnchors[0] + + expect(anchor.getAttribute('href')).toMatch(/^data:text\/json/) + expect(anchor.click).toHaveBeenCalledOnce() + + vi.restoreAllMocks() + }) + + it('sets the download filename to secuscan_config_.json', async () => { + const user = userEvent.setup() + renderSettings() + + const createdAnchors: HTMLAnchorElement[] = [] + const originalCreate = document.createElement.bind(document) + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + createdAnchors.push(el as HTMLAnchorElement) + } + return el + }) + + await user.click(screen.getByRole('button', { name: /TELEMETRY_EXPORT/i })) + + const today = new Date().toISOString().split('T')[0] + expect(createdAnchors[0].getAttribute('download')).toBe( + `secuscan_config_${today}.json`, + ) + + vi.restoreAllMocks() + }) + + it('exports a valid JSON string containing the current config', async () => { + const user = userEvent.setup() + renderSettings() + + const createdAnchors: HTMLAnchorElement[] = [] + const originalCreate = document.createElement.bind(document) + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + createdAnchors.push(el as HTMLAnchorElement) + } + return el + }) + + await user.click(screen.getByRole('button', { name: /TELEMETRY_EXPORT/i })) + + const href = createdAnchors[0].getAttribute('href') ?? '' + const jsonStr = decodeURIComponent( + href.replace('data:text/json;charset=utf-8,', ''), + ) + const exported = JSON.parse(jsonStr) + + expect(exported.concurrentScans).toBe(8) + expect(exported.scanIntensity).toBe('standard') + expect(exported.theme).toBe('dark') + + vi.restoreAllMocks() + }) + + it('removes the anchor from the DOM after the download is triggered', async () => { + const user = userEvent.setup() + renderSettings() + + const createdAnchors: HTMLAnchorElement[] = [] + const originalCreate = document.createElement.bind(document) + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + createdAnchors.push(el as HTMLAnchorElement) + } + return el + }) + + await user.click(screen.getByRole('button', { name: /TELEMETRY_EXPORT/i })) + + expect(document.body.contains(createdAnchors[0])).toBe(false) + + vi.restoreAllMocks() + }) +}) \ No newline at end of file diff --git a/frontend/testing/unit/pages/SettingsTheme.test.tsx b/frontend/testing/unit/pages/SettingsTheme.test.tsx index 88d6760f7..01d68dd22 100644 --- a/frontend/testing/unit/pages/SettingsTheme.test.tsx +++ b/frontend/testing/unit/pages/SettingsTheme.test.tsx @@ -1,9 +1,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { vi, describe, it, expect, beforeEach } from 'vitest' import Settings from '../../../src/pages/Settings' import { ThemeProvider } from '../../../src/components/ThemeContext' import { ToastProvider } from '../../../src/components/ToastContext' -import { vi } from 'vitest' import { listNotificationRules } from '../../../src/api' vi.mock('../../../src/api', async () => { @@ -14,6 +14,16 @@ vi.mock('../../../src/api', async () => { } }) +function renderSettings() { + render( + + + + + , + ) +} + describe('Settings theme wiring', () => { beforeEach(() => { window.localStorage.removeItem('secuscan-theme') @@ -21,88 +31,31 @@ describe('Settings theme wiring', () => { vi.mocked(listNotificationRules).mockResolvedValue([]) }) - describe('Settings management tools', () => { - it('saves configuration to localStorage', async () => { - const user = userEvent.setup() - render( - - - - - , - ) - await user.click( - screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i }), - ) - expect(localStorage.getItem('secuscan-config')).not.toBeNull() - }) - - it('opens reset confirmation modal', async () => { - const user = userEvent.setup() - render( - - - - - , - ) - await user.click( - screen.getByRole('button', { name: /ENGINE_RESET/i }), - ) - expect( - screen.getByText(/Restore engine to factory specifications/i), - ).toBeInTheDocument() - }) - }) - it('applies selected theme globally and persists it', async () => { const user = userEvent.setup() - render( - - - - - , - ) + renderSettings() + const themeSelect = screen.getByRole('combobox', { name: /visual spectrum theme/i }) + await user.selectOptions(themeSelect, 'light') await user.click(screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i })) expect(document.documentElement.classList.contains('theme-light')).toBe(true) expect(window.localStorage.getItem('secuscan-theme')).toBe('light') + await user.selectOptions(themeSelect, 'dark') await user.click(screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i })) expect(document.documentElement.classList.contains('theme-light')).toBe(false) expect(window.localStorage.getItem('secuscan-theme')).toBe('dark') }) - it.skip('verifies export flow with a test-friendly stub', async () => { + it('opens reset confirmation modal when ENGINE_RESET is clicked', async () => { const user = userEvent.setup() - const clickMock = vi.fn() - const anchorMock = { - setAttribute: vi.fn(), - click: clickMock, - remove: vi.fn(), - } - const originalCreateElement = document.createElement.bind(document) - const createElementSpy = vi - .spyOn(document, 'createElement') - .mockImplementation((tagName: string) => { - if (tagName === 'a') { - return anchorMock as any - } - return originalCreateElement(tagName) - }) - render( - - - - - , - ) - await user.click( - screen.getByRole('button', { name: /TELEMETRY_EXPORT/i }), - ) - expect(clickMock).toHaveBeenCalled() - createElementSpy.mockRestore() + renderSettings() + + await user.click(screen.getByRole('button', { name: /ENGINE_RESET/i })) + + expect( + screen.getByText(/Restore engine to factory specifications/i), + ).toBeInTheDocument() }) -}) +}) \ No newline at end of file From 67ba8d204adf1495dcc4936dc49af75f0bc4bfa9 Mon Sep 17 00:00:00 2001 From: Naitik Date: Tue, 9 Jun 2026 22:07:08 +0530 Subject: [PATCH 4/5] test(settings): remove BOM and add final newline to Settings test files --- frontend/testing/unit/pages/SettingsExport.test.tsx | 2 +- frontend/testing/unit/pages/SettingsTheme.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/testing/unit/pages/SettingsExport.test.tsx b/frontend/testing/unit/pages/SettingsExport.test.tsx index e8191bf24..2df6de2ca 100644 --- a/frontend/testing/unit/pages/SettingsExport.test.tsx +++ b/frontend/testing/unit/pages/SettingsExport.test.tsx @@ -136,4 +136,4 @@ describe('Settings export flow', () => { vi.restoreAllMocks() }) -}) \ No newline at end of file +}) diff --git a/frontend/testing/unit/pages/SettingsTheme.test.tsx b/frontend/testing/unit/pages/SettingsTheme.test.tsx index 01d68dd22..95f3c9b9f 100644 --- a/frontend/testing/unit/pages/SettingsTheme.test.tsx +++ b/frontend/testing/unit/pages/SettingsTheme.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi, describe, it, expect, beforeEach } from 'vitest' import Settings from '../../../src/pages/Settings' @@ -58,4 +58,4 @@ describe('Settings theme wiring', () => { screen.getByText(/Restore engine to factory specifications/i), ).toBeInTheDocument() }) -}) \ No newline at end of file +}) From 68b548a6a309a522bd778b818496c303b4041db6 Mon Sep 17 00:00:00 2001 From: Naitik Date: Tue, 9 Jun 2026 22:13:48 +0530 Subject: [PATCH 5/5] test(settings): remove explicit vitest imports to match existing file style --- frontend/testing/unit/pages/SettingsTheme.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/testing/unit/pages/SettingsTheme.test.tsx b/frontend/testing/unit/pages/SettingsTheme.test.tsx index 95f3c9b9f..36ee93c74 100644 --- a/frontend/testing/unit/pages/SettingsTheme.test.tsx +++ b/frontend/testing/unit/pages/SettingsTheme.test.tsx @@ -1,6 +1,5 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { vi, describe, it, expect, beforeEach } from 'vitest' import Settings from '../../../src/pages/Settings' import { ThemeProvider } from '../../../src/components/ThemeContext' import { ToastProvider } from '../../../src/components/ToastContext'