From 68152d880f399953e9a142c04a59e285bbd9cefa Mon Sep 17 00:00:00 2001 From: Jerad Rutnam Date: Thu, 30 Apr 2026 13:31:58 +0530 Subject: [PATCH] Add test coverage for import-export feature Co-authored-by: Copilot --- .../__tests__/useExportConfiguration.test.ts | 139 ++++++++++++ .../__tests__/useImportConfiguration.test.ts | 138 ++++++++++++ .../pages/__tests__/ExportPage.test.tsx | 173 +++++++++++++++ .../ImportConfigurationUploadPage.test.tsx | 200 ++++++++++++++++++ .../ImportConfigurationValidatePage.test.tsx | 155 ++++++++++++++ .../__tests__/CreateProjectPage.test.tsx | 113 ++++++++++ .../pages/__tests__/WelcomePage.test.tsx | 157 ++++++++++++++ .../thunder-contexts/tsconfig.lib.json | 1 + 8 files changed, 1076 insertions(+) create mode 100644 frontend/apps/thunder-console/src/features/import-export/api/__tests__/useExportConfiguration.test.ts create mode 100644 frontend/apps/thunder-console/src/features/import-export/api/__tests__/useImportConfiguration.test.ts create mode 100644 frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ExportPage.test.tsx create mode 100644 frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ImportConfigurationUploadPage.test.tsx create mode 100644 frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ImportConfigurationValidatePage.test.tsx create mode 100644 frontend/apps/thunder-console/src/features/welcome/pages/__tests__/CreateProjectPage.test.tsx create mode 100644 frontend/apps/thunder-console/src/features/welcome/pages/__tests__/WelcomePage.test.tsx diff --git a/frontend/apps/thunder-console/src/features/import-export/api/__tests__/useExportConfiguration.test.ts b/frontend/apps/thunder-console/src/features/import-export/api/__tests__/useExportConfiguration.test.ts new file mode 100644 index 0000000000..b6da16ac47 --- /dev/null +++ b/frontend/apps/thunder-console/src/features/import-export/api/__tests__/useExportConfiguration.test.ts @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 {waitFor} from '@testing-library/react'; +import {renderHook} from '@thunder/test-utils'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {ExportRequest, JSONExportResponse} from '../../models/export-configuration'; + +const mockHttpRequest = vi.fn(); +vi.mock('@asgardeo/react', () => ({ + useAsgardeo: () => ({ + http: {request: mockHttpRequest}, + }), +})); + +const mockGetServerUrl = vi.fn<() => string>(() => 'https://localhost:8090'); +vi.mock('@thunder/contexts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useConfig: () => ({getServerUrl: mockGetServerUrl}), + }; +}); + +const {default: useExportConfiguration} = await import('../useExportConfiguration'); + +describe('useExportConfiguration', () => { + const mockRequest: ExportRequest = { + applications: ['*'], + identityProviders: ['*'], + flows: ['*'], + }; + + const mockResponse: JSONExportResponse = { + resources: '---\n# resource_type: application\nname: test-app\n', + environment_variables: 'ENV_VAR=value', + summary: {totalFiles: 1, exported: {application: 1}, skipped: {}}, + } as unknown as JSONExportResponse; + + beforeEach(() => { + mockHttpRequest.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('initializes with idle state', () => { + const {result} = renderHook(() => useExportConfiguration()); + + expect(result.current.isIdle).toBe(true); + expect(result.current.data).toBeUndefined(); + }); + + it('exports configuration successfully', async () => { + mockHttpRequest.mockResolvedValue({data: mockResponse}); + + const {result} = renderHook(() => useExportConfiguration()); + result.current.mutate(mockRequest); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockResponse); + expect(mockHttpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://localhost:8090/export', + method: 'POST', + data: mockRequest, + }), + ); + }); + + it('sets isPending during export', async () => { + let resolveRequest: (value: unknown) => void; + const requestPromise = new Promise((resolve) => { + resolveRequest = resolve; + }); + mockHttpRequest.mockReturnValue(requestPromise); + + const {result} = renderHook(() => useExportConfiguration()); + result.current.mutate(mockRequest); + + await waitFor(() => { + expect(result.current.isPending).toBe(true); + }); + + resolveRequest!({data: mockResponse}); + + await waitFor(() => { + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + }); + + it('surfaces error on failure', async () => { + mockHttpRequest.mockRejectedValue(new Error('Export failed')); + + const {result} = renderHook(() => useExportConfiguration()); + result.current.mutate(mockRequest); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error?.message).toBe('Export failed'); + }); + + it('sends Content-Type application/json header', async () => { + mockHttpRequest.mockResolvedValue({data: mockResponse}); + + const {result} = renderHook(() => useExportConfiguration()); + result.current.mutate(mockRequest); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(mockHttpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({'Content-Type': 'application/json'}) as Record, + }), + ); + }); +}); diff --git a/frontend/apps/thunder-console/src/features/import-export/api/__tests__/useImportConfiguration.test.ts b/frontend/apps/thunder-console/src/features/import-export/api/__tests__/useImportConfiguration.test.ts new file mode 100644 index 0000000000..035e606e05 --- /dev/null +++ b/frontend/apps/thunder-console/src/features/import-export/api/__tests__/useImportConfiguration.test.ts @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 {waitFor} from '@testing-library/react'; +import {renderHook} from '@thunder/test-utils'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {ImportRequest, ImportResponse} from '../../models/import-configuration'; + +const mockHttpRequest = vi.fn(); +vi.mock('@asgardeo/react', () => ({ + useAsgardeo: () => ({ + http: {request: mockHttpRequest}, + }), +})); + +const mockGetServerUrl = vi.fn<() => string>(() => 'https://localhost:8090'); +vi.mock('@thunder/contexts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useConfig: () => ({getServerUrl: mockGetServerUrl}), + }; +}); + +const {default: useImportConfiguration} = await import('../useImportConfiguration'); + +describe('useImportConfiguration', () => { + const mockRequest: ImportRequest = { + dryRun: true, + resources: {application: [{name: 'test-app'}]}, + environmentVariables: {}, + } as unknown as ImportRequest; + + const mockResponse: ImportResponse = { + summary: {total: 1, succeeded: 1, failed: 0}, + results: [], + } as unknown as ImportResponse; + + beforeEach(() => { + mockHttpRequest.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('initializes with idle state', () => { + const {result} = renderHook(() => useImportConfiguration()); + + expect(result.current.isIdle).toBe(true); + expect(result.current.data).toBeUndefined(); + }); + + it('imports configuration successfully', async () => { + mockHttpRequest.mockResolvedValue({data: mockResponse}); + + const {result} = renderHook(() => useImportConfiguration()); + result.current.mutate(mockRequest); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockResponse); + expect(mockHttpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://localhost:8090/import', + method: 'POST', + data: mockRequest, + }), + ); + }); + + it('sets isPending during import', async () => { + let resolveRequest: (value: unknown) => void; + const requestPromise = new Promise((resolve) => { + resolveRequest = resolve; + }); + mockHttpRequest.mockReturnValue(requestPromise); + + const {result} = renderHook(() => useImportConfiguration()); + result.current.mutate(mockRequest); + + await waitFor(() => { + expect(result.current.isPending).toBe(true); + }); + + resolveRequest!({data: mockResponse}); + + await waitFor(() => { + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + }); + + it('surfaces error on failure', async () => { + mockHttpRequest.mockRejectedValue(new Error('Import failed')); + + const {result} = renderHook(() => useImportConfiguration()); + result.current.mutate(mockRequest); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error?.message).toBe('Import failed'); + }); + + it('sends Content-Type application/json header', async () => { + mockHttpRequest.mockResolvedValue({data: mockResponse}); + + const {result} = renderHook(() => useImportConfiguration()); + result.current.mutate(mockRequest); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(mockHttpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({'Content-Type': 'application/json'}) as Record, + }), + ); + }); +}); diff --git a/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ExportPage.test.tsx b/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ExportPage.test.tsx new file mode 100644 index 0000000000..ba97bd7d0a --- /dev/null +++ b/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ExportPage.test.tsx @@ -0,0 +1,173 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 {UseMutationResult} from '@tanstack/react-query'; +import {render, screen, userEvent} from '@thunder/test-utils'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {ExportRequest, JSONExportResponse} from '../../models/export-configuration'; + +const mockNavigate = vi.fn(); +const mockMutate = vi.fn(); +const mockLogger = {error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn()}; + +let mockMutationState: Partial> = { + mutate: mockMutate, + data: undefined, + isPending: false, + isError: false, + error: null, +}; + +vi.mock('../../api/useExportConfiguration', () => ({ + default: () => mockMutationState, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({t: (key: string) => key}), +})); + +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return {...actual, useNavigate: () => mockNavigate}; +}); + +vi.mock('@thunder/logger/react', () => ({ + useLogger: () => mockLogger, +})); + +vi.mock('@wso2/oxygen-ui-icons-react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + X: () => , + }; +}); + +vi.mock('../../components/ConfigureExport', () => ({ + default: ({resources, environmentVariables}: {resources: string; environmentVariables: string}) => ( +
+ ConfigureExport +
+ ), +})); + +import ExportPage from '../ExportPage'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('ExportPage', () => { + describe('on mount', () => { + it('calls mutate with all resource wildcards', () => { + render(); + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + applications: ['*'], + identityProviders: ['*'], + flows: ['*'], + }), + ); + }); + }); + + describe('loading state', () => { + beforeEach(() => { + mockMutationState = {...mockMutationState, isPending: true, data: undefined, isError: false} as Partial< + UseMutationResult + >; + }); + + it('renders loading indicator', () => { + render(); + expect(screen.getByText('export.page.loading')).toBeInTheDocument(); + }); + + it('does not render ConfigureExport while loading', () => { + render(); + expect(screen.queryByTestId('configure-export')).not.toBeInTheDocument(); + }); + }); + + describe('error state', () => { + beforeEach(() => { + mockMutationState = { + ...mockMutationState, + isPending: false, + isError: true, + error: new Error('Network error'), + data: undefined, + } as Partial>; + }); + + it('renders error alert', () => { + render(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('does not render ConfigureExport on error', () => { + render(); + expect(screen.queryByTestId('configure-export')).not.toBeInTheDocument(); + }); + }); + + describe('success state', () => { + beforeEach(() => { + mockMutationState = { + mutate: mockMutate, + isPending: false, + isError: false, + error: null, + data: { + resources: 'resource-content', + environment_variables: 'ENV_VAR=value', + summary: {totalFiles: 1, exported: {}, skipped: {}}, + } as unknown as JSONExportResponse, + }; + }); + + it('renders ConfigureExport with resource data', () => { + render(); + const configureExport = screen.getByTestId('configure-export'); + expect(configureExport).toBeInTheDocument(); + expect(configureExport).toHaveAttribute('data-resources', 'resource-content'); + }); + + it('passes environment_variables to ConfigureExport', () => { + render(); + expect(screen.getByTestId('configure-export')).toHaveAttribute('data-env', 'ENV_VAR=value'); + }); + }); + + describe('navigation', () => { + it('renders page title', () => { + render(); + expect(screen.getByText('export.page.title')).toBeInTheDocument(); + }); + + it('calls navigate(-1) when close button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: 'common:actions.close'})); + + expect(mockNavigate).toHaveBeenCalledWith(-1); + }); + }); +}); diff --git a/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ImportConfigurationUploadPage.test.tsx b/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ImportConfigurationUploadPage.test.tsx new file mode 100644 index 0000000000..898851976d --- /dev/null +++ b/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ImportConfigurationUploadPage.test.tsx @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 {render, screen, userEvent, waitFor, fireEvent} from '@thunder/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const mockNavigate = vi.fn(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({t: (key: string) => key}), +})); + +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return {...actual, useNavigate: () => mockNavigate}; +}); + +vi.mock('@thunder/logger/react', () => ({ + useLogger: () => ({error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn()}), +})); + +vi.mock('@wso2/oxygen-ui-icons-react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ChevronRight: () => , + Upload: () => , + X: () => , + }; +}); + +import ImportConfigurationUploadPage from '../ImportConfigurationUploadPage'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('ImportConfigurationUploadPage', () => { + it('renders without crashing', () => { + const {container} = render(); + expect(container).toBeInTheDocument(); + }); + + it('renders upload title', () => { + render(); + expect(screen.getByText('upload.title')).toBeInTheDocument(); + }); + + it('renders the file drop area', () => { + render(); + expect(screen.getByText('upload.dropConfig')).toBeInTheDocument(); + }); + + it('renders the env file drop area', () => { + render(); + expect(screen.getByText('upload.env.title')).toBeInTheDocument(); + }); + + it('renders cancel and continue buttons', () => { + render(); + expect(screen.getByRole('button', {name: 'common:actions.cancel'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'common:actions.continue'})).toBeInTheDocument(); + }); + + it('continue button is disabled when no files selected', () => { + render(); + expect(screen.getByRole('button', {name: 'common:actions.continue'})).toBeDisabled(); + }); + + it('navigates to /welcome on cancel', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: 'common:actions.cancel'})); + + expect(mockNavigate).toHaveBeenCalledWith('/welcome'); + }); + + it('navigates to /home on close', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: 'common:actions.close'})); + + expect(mockNavigate).toHaveBeenCalledWith('/home'); + }); + + it('shows error when non-yaml file is selected', () => { + render(); + + const input = document.getElementById('file-upload') as HTMLInputElement; + const file = new File(['content'], 'config.txt', {type: 'text/plain'}); + fireEvent.change(input, {target: {files: [file]}}); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('upload.errors.uploadYaml')).toBeInTheDocument(); + }); + + it('shows file name after valid yaml file is selected', async () => { + const user = userEvent.setup(); + render(); + + const input = document.getElementById('file-upload') as HTMLInputElement; + const file = new File(['key: value'], 'config.yaml', {type: 'text/yaml'}); + await user.upload(input, file); + + expect(screen.getByText('config.yaml')).toBeInTheDocument(); + }); + + it('shows error when non-env file is selected for env input', () => { + render(); + + const input = document.getElementById('env-file-upload') as HTMLInputElement; + const file = new File(['KEY=VALUE'], 'secrets.txt', {type: 'text/plain'}); + fireEvent.change(input, {target: {files: [file]}}); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('upload.errors.uploadEnv')).toBeInTheDocument(); + }); + + it('shows env file name after valid .env file is selected', async () => { + const user = userEvent.setup(); + render(); + + const input = document.getElementById('env-file-upload') as HTMLInputElement; + const file = new File(['KEY=VALUE'], '.env', {type: 'text/plain'}); + await user.upload(input, file); + + expect(screen.getByText('.env')).toBeInTheDocument(); + }); + + it('continue button becomes enabled after both files are selected', async () => { + const user = userEvent.setup(); + render(); + + const yamlFile = new File(['key: value'], 'config.yaml', {type: 'text/yaml'}); + await user.upload(document.getElementById('file-upload') as HTMLInputElement, yamlFile); + + const envFile = new File(['KEY=VALUE'], '.env', {type: 'text/plain'}); + await user.upload(document.getElementById('env-file-upload') as HTMLInputElement, envFile); + + expect(screen.getByRole('button', {name: 'common:actions.continue'})).not.toBeDisabled(); + }); + + it('shows error when continue is clicked without env file', async () => { + const user = userEvent.setup(); + render(); + + // Only select yaml file + const yamlFile = new File(['key: value'], 'config.yaml', {type: 'text/yaml'}); + await user.upload(document.getElementById('file-upload') as HTMLInputElement, yamlFile); + + // Manually enable the button by patching disabled - instead verify error shows when submitting + // The button is disabled without envFile so this tests the validation guard + expect(screen.getByRole('button', {name: 'common:actions.continue'})).toBeDisabled(); + }); + + it('navigates to validate page after both valid files are provided', async () => { + const user = userEvent.setup(); + render(); + + const yamlContent = '---\n# resource_type: application\nname: test-app\n'; + const envContent = 'KEY=VALUE'; + const yamlFile = new File([yamlContent], 'config.yaml', {type: 'text/yaml'}); + const envFile = new File([envContent], '.env', {type: 'text/plain'}); + + // jsdom does not implement File.prototype.text(); provide it for the async handler + Object.defineProperty(yamlFile, 'text', {value: () => Promise.resolve(yamlContent)}); + Object.defineProperty(envFile, 'text', {value: () => Promise.resolve(envContent)}); + + await user.upload(document.getElementById('file-upload') as HTMLInputElement, yamlFile); + await user.upload(document.getElementById('env-file-upload') as HTMLInputElement, envFile); + await user.click(screen.getByRole('button', {name: 'common:actions.continue'})); + + await waitFor( + () => { + expect(mockNavigate).toHaveBeenCalledWith( + '/welcome/open-project/validate', + expect.objectContaining({state: expect.objectContaining({method: 'file'}) as Record}), + ); + }, + {timeout: 5000}, + ); + }); +}); diff --git a/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ImportConfigurationValidatePage.test.tsx b/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ImportConfigurationValidatePage.test.tsx new file mode 100644 index 0000000000..69979d5623 --- /dev/null +++ b/frontend/apps/thunder-console/src/features/import-export/pages/__tests__/ImportConfigurationValidatePage.test.tsx @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 {render, screen, userEvent} from '@thunder/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const mockNavigate = vi.fn(); +let mockLocationState: unknown = null; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => (opts ? `${key}:${JSON.stringify(opts)}` : key), + }), +})); + +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { + ...actual, + useNavigate: () => mockNavigate, + useLocation: () => ({state: mockLocationState, pathname: '/welcome/open-project/validate'}), + }; +}); + +const mockLogger = {error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn()}; + +vi.mock('@thunder/logger/react', () => ({ + useLogger: () => mockLogger, +})); + +vi.mock('@wso2/oxygen-ui-icons-react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ChevronRight: () => , + X: () => , + CheckCircle: () => , + AlertCircle: () => , + }; +}); + +import ImportConfigurationValidatePage from '../ImportConfigurationValidatePage'; + +afterEach(() => { + vi.clearAllMocks(); + mockLocationState = null; +}); + +describe('ImportConfigurationValidatePage', () => { + it('renders without crashing', () => { + const {container} = render(); + expect(container).toBeInTheDocument(); + }); + + it('renders validate title', () => { + render(); + expect(screen.getByText('validate.title')).toBeInTheDocument(); + }); + + it('renders the four validation steps', () => { + render(); + expect(screen.getByText('validate.steps.readingFile')).toBeInTheDocument(); + expect(screen.getByText('validate.steps.validatingYaml')).toBeInTheDocument(); + expect(screen.getByText('validate.steps.checkingCompatibility')).toBeInTheDocument(); + expect(screen.getByText('validate.steps.validatingResources')).toBeInTheDocument(); + }); + + it('renders close button', () => { + render(); + expect(screen.getByRole('button', {name: 'common:actions.close'})).toBeInTheDocument(); + }); + + it('navigates to /home on close', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: 'common:actions.close'})); + + expect(mockNavigate).toHaveBeenCalledWith('/home'); + }); + + it('navigates to /welcome on cancel (no errors)', () => { + mockLocationState = {parseErrors: [], configData: {application: []}}; + render(); + + const cancelButton = screen.queryByRole('button', {name: 'common:actions.cancel'}); + // Cancel is only shown when there are parse errors + expect(cancelButton).not.toBeInTheDocument(); + }); + + it('shows parse errors when state has parse errors', () => { + mockLocationState = { + parseErrors: [{resourceType: 'unknown_type', fileName: 'bad.yaml', error: 'unexpected token'}], + parseStats: {successCount: 2, failCount: 1}, + }; + + render(); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('shows upload different file button when parse errors exist', () => { + mockLocationState = { + parseErrors: [{resourceType: 'bad_type', fileName: 'config.yaml', error: 'parse error'}], + parseStats: {successCount: 0, failCount: 1}, + }; + + render(); + + expect(screen.getByRole('button', {name: 'validate.actions.uploadDifferentFile'})).toBeInTheDocument(); + }); + + it('navigates to /welcome/open-project when upload different file is clicked', async () => { + mockLocationState = { + parseErrors: [{resourceType: 'bad_type', fileName: 'config.yaml', error: 'parse error'}], + parseStats: {successCount: 0, failCount: 1}, + }; + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: 'validate.actions.uploadDifferentFile'})); + + expect(mockNavigate).toHaveBeenCalledWith('/welcome/open-project'); + }); + + it('renders breadcrumb with welcome header', () => { + render(); + expect(screen.getByText('common:welcome.header')).toBeInTheDocument(); + }); + + it('navigates to /welcome when breadcrumb welcome is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('common:welcome.header')); + + expect(mockNavigate).toHaveBeenCalledWith('/welcome'); + }); +}); diff --git a/frontend/apps/thunder-console/src/features/welcome/pages/__tests__/CreateProjectPage.test.tsx b/frontend/apps/thunder-console/src/features/welcome/pages/__tests__/CreateProjectPage.test.tsx new file mode 100644 index 0000000000..533a90fb64 --- /dev/null +++ b/frontend/apps/thunder-console/src/features/welcome/pages/__tests__/CreateProjectPage.test.tsx @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 {render, screen, userEvent} from '@thunder/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const mockNavigate = vi.fn(); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({t: (key: string) => key}), +})); + +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return {...actual, useNavigate: () => mockNavigate}; +}); + +vi.mock('framer-motion', () => ({ + motion: { + create: (Component: React.ElementType) => Component, + }, +})); + +vi.mock('@wso2/oxygen-ui-icons-react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ChevronRight: () => , + X: () => , + Settings: () => , + PlayCircle: () => , + CheckCircle: () => , + }; +}); + +vi.mock('@/assets/images/illustrations/how-thunder-id-solution-works.svg?react', () => ({ + default: () => , +})); + +import CreateProjectPage from '../CreateProjectPage'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('CreateProjectPage', () => { + it('renders without crashing', () => { + const {container} = render(); + expect(container).toBeInTheDocument(); + }); + + it('renders close button', () => { + render(); + expect(screen.getByRole('button', {name: 'common:actions.close'})).toBeInTheDocument(); + }); + + it('renders the page title', () => { + render(); + expect(screen.getByText('common:welcome.createProject.title')).toBeInTheDocument(); + }); + + it('renders the get started button', () => { + render(); + expect(screen.getByRole('button', {name: 'common:welcome.createProject.actions.getStarted'})).toBeInTheDocument(); + }); + + it('navigates to /home when close button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: 'common:actions.close'})); + + expect(mockNavigate).toHaveBeenCalledWith('/home'); + }); + + it('navigates to /home when get started button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: 'common:welcome.createProject.actions.getStarted'})); + + expect(mockNavigate).toHaveBeenCalledWith('/home'); + }); + + it('renders breadcrumb with welcome header', () => { + render(); + expect(screen.getByText('common:welcome.header')).toBeInTheDocument(); + }); + + it('navigates to /welcome when breadcrumb welcome is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('common:welcome.header')); + + expect(mockNavigate).toHaveBeenCalledWith('/welcome'); + }); +}); diff --git a/frontend/apps/thunder-console/src/features/welcome/pages/__tests__/WelcomePage.test.tsx b/frontend/apps/thunder-console/src/features/welcome/pages/__tests__/WelcomePage.test.tsx new file mode 100644 index 0000000000..5d7a3b3506 --- /dev/null +++ b/frontend/apps/thunder-console/src/features/welcome/pages/__tests__/WelcomePage.test.tsx @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 {render, screen, userEvent} from '@thunder/test-utils'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const mockNavigate = vi.fn(); +const mockSessionStorageSetItem = vi.fn(); + +vi.mock('@thunder/contexts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useConfig: () => ({ + config: {brand: {product_name: 'Thunder'}}, + }), + }; +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (opts?.productName) { + const productName = opts.productName as string; + return `${key}:${productName}`; + } + return key; + }, + }), +})); + +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return {...actual, useNavigate: () => mockNavigate}; +}); + +vi.mock('@wso2/oxygen-ui', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTheme: () => ({palette: {mode: 'light'}}), + }; +}); + +vi.mock('framer-motion', () => ({ + motion: { + create: (Component: React.ElementType) => Component, + }, +})); + +vi.mock('@wso2/oxygen-ui-icons-react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FolderOpen: () => , + X: () => , + ChevronRight: () => , + BookOpen: () => , + Lightbulb: () => , + PackagePlus: () => , + }; +}); + +import WelcomePage from '../WelcomePage'; + +describe('WelcomePage', () => { + beforeEach(() => { + vi.stubGlobal('sessionStorage', { + setItem: mockSessionStorageSetItem, + getItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + vi.stubGlobal('open', vi.fn()); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('renders without crashing', () => { + const {container} = render(); + expect(container).toBeInTheDocument(); + }); + + it('renders close button', () => { + render(); + expect(screen.getByRole('button', {name: /common:actions\.close/i})).toBeInTheDocument(); + }); + + it('navigates to /home and sets session storage on close', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: /common:actions\.close/i})); + + expect(mockSessionStorageSetItem).toHaveBeenCalledWith('thunder:welcome:dismissed', 'true'); + expect(mockNavigate).toHaveBeenCalledWith('/home'); + }); + + it('navigates to /welcome/create-project on new project click', async () => { + const user = userEvent.setup(); + render(); + + const newProjectButton = screen.getByText('common:welcome.start.newProject'); + await user.click(newProjectButton.closest('[role="button"]') ?? newProjectButton); + + expect(mockSessionStorageSetItem).toHaveBeenCalledWith('thunder:welcome:dismissed', 'true'); + expect(mockNavigate).toHaveBeenCalledWith('/welcome/create-project'); + }); + + it('renders start action items', () => { + render(); + expect(screen.getByText('common:welcome.start.newProject')).toBeInTheDocument(); + expect(screen.getByText('common:welcome.start.openImport')).toBeInTheDocument(); + }); + + it('renders walkthrough items', () => { + render(); + expect(screen.getByText('common:welcome.walkthrough.getStartedDesigner')).toBeInTheDocument(); + expect(screen.getByText('common:welcome.walkthrough.learnFundamentals')).toBeInTheDocument(); + }); + + it('opens external link with noopener,noreferrer on walkthrough click', async () => { + const mockOpen = vi.fn(); + vi.stubGlobal('open', mockOpen); + const user = userEvent.setup(); + render(); + + const getStartedButton = screen.getByText('common:welcome.walkthrough.getStartedDesigner'); + await user.click(getStartedButton.closest('[role="button"]') ?? getStartedButton); + + expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining('quickstart'), '_blank', 'noopener,noreferrer'); + }); + + it('uses product name from config', () => { + render(); + // The openImportDesc key is interpolated with productName + expect(screen.getByText(/openImportDesc.*Thunder/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/thunder-contexts/tsconfig.lib.json b/frontend/packages/thunder-contexts/tsconfig.lib.json index 8d37e00dfd..9572eb45a9 100644 --- a/frontend/packages/thunder-contexts/tsconfig.lib.json +++ b/frontend/packages/thunder-contexts/tsconfig.lib.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, + "rootDir": "src", "outDir": "dist", "declarationDir": "dist", "types": ["node"]