diff --git a/.github/workflows/lwa-github-ci-workflow.yml b/.github/workflows/lwa-github-ci-workflow.yml new file mode 100644 index 00000000..787f02a9 --- /dev/null +++ b/.github/workflows/lwa-github-ci-workflow.yml @@ -0,0 +1,126 @@ +name: Login Web App CI Workflow + +on: + push: + paths: + - 'src/login-web-app/**' + pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] + paths: + - 'src/login-web-app/**' + workflow_dispatch: + +jobs: + build-and-test: + if: ${{ github.event.pull_request.draft != true || github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Node.js from .nvmrc + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Lint Code + run: npm run lint -w src/login-web-app + + - name: Build Project + run: npm run build:login-web-app + + - name: Run Tests + run: npm run test -w src/login-web-app -- run + + notify-slack: + name: Notify Slack + needs: build-and-test + runs-on: ubuntu-latest + if: ${{ always() && github.event_name == 'pull_request' }} + strategy: + matrix: + webhook_secret: [LWA_SLACK_WEBHOOK_URL, LWA_SLACK_WEBHOOK_URL_FE_CHANNEL] + steps: + - name: Extract Jira ticket from branch name + id: jira + run: | + TICKET=$(echo "${{ github.head_ref }}" | grep -oE '[A-Z]+-[0-9]+' | head -1) + if [ -n "$TICKET" ]; then + echo "link=*Jira:* " >> $GITHUB_OUTPUT + else + echo "link=" >> $GITHUB_OUTPUT + fi + + - name: Notify new pull request + if: ${{ (github.event.action == 'opened' && github.event.pull_request.draft == false) || github.event.action == 'ready_for_review' }} + uses: slackapi/slack-github-action@v1.26.0 + with: + payload: | + { + "text": ":rocket: PR ready for review in `${{ github.repository }}`", + "attachments": [ + { + "color": "${{ needs.build-and-test.result == 'success' && '#2EB67D' || '#E01E5A' }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Title:* ${{ github.event.pull_request.title }}\n*Author:* ${{ github.actor }}\n*Status:* ${{ needs.build-and-test.result == 'success' && ':white_check_mark: Passed' || ':x: Failed' }}\n*PR:* <${{ github.event.pull_request.html_url }}|View Pull Request> | <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow Logs>${{ steps.jira.outputs.link && format('\n{0}', steps.jira.outputs.link) || '' }}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ${{ toJSON(format('*Description:*{0}{1}', fromJSON('"\n"'), github.event.pull_request.body || 'No description provided.')) }} + } + } + ] + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets[matrix.webhook_secret] }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + + - name: Notify merged pull request + if: ${{ github.event.action == 'closed' && github.event.pull_request.merged == true }} + uses: slackapi/slack-github-action@v1.26.0 + with: + payload: | + { + "text": ":tada: PR merged in `${{ github.repository }}`", + "attachments": [ + { + "color": "${{ needs.build-and-test.result == 'success' && '#2EB67D' || '#E01E5A' }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Title:* ${{ github.event.pull_request.title }}\n*Merged by:* ${{ github.actor }}\n*Status:* ${{ needs.build-and-test.result == 'success' && ':white_check_mark: Passed' || ':x: Failed' }}\n*PR:* <${{ github.event.pull_request.html_url }}|View Pull Request> | <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow Logs>${{ steps.jira.outputs.link && format('\n{0}', steps.jira.outputs.link) || '' }}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ${{ toJSON(format('*Description:*{0}{1}', fromJSON('"\n"'), github.event.pull_request.body || 'No description provided.')) }} + } + } + ] + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets[matrix.webhook_secret] }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK diff --git a/package-lock.json b/package-lock.json index 1836375b..bcf3722c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21817,7 +21817,7 @@ "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", "vite": "^7.3.1", - "vitest": "^4.0.1" + "vitest": "~4.0.6" }, "engines": { "node": ">=22.22.0", @@ -21838,6 +21838,90 @@ "lru-cache": "^10.4.3" } }, + "src/login-web-app/node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "src/login-web-app/node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -21952,6 +22036,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "src/login-web-app/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "src/login-web-app/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "src/login-web-app/node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -21979,6 +22083,111 @@ "node": ">=14.17" } }, + "src/login-web-app/node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "src/login-web-app/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "src/login-web-app/node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/src/login-web-app/package.json b/src/login-web-app/package.json index 91c412ad..f2d8645f 100644 --- a/src/login-web-app/package.json +++ b/src/login-web-app/package.json @@ -51,6 +51,6 @@ "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", "vite": "^7.3.1", - "vitest": "^4.0.1" + "vitest": "~4.0.6" } } \ No newline at end of file diff --git a/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-utils.spec.ts b/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-utils.spec.ts index 3dd0ce8b..0b1c4554 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-utils.spec.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-utils.spec.ts @@ -95,7 +95,7 @@ describe(createRequestForForm.name, () => { b: 'b', }; - expect(() => createRequestForForm({ action, payload })).toThrowError(); + expect(() => createRequestForForm({ action, payload })).toThrow(); }); }); @@ -123,7 +123,7 @@ describe(createRequestForForm.name, () => { const action = formAction(HTTP_METHODS.GET, '/example/path', { a: '1' }, MEDIA_TYPES.JSON); const payload = { a: 'a' }; - expect(() => createRequestForForm({ action, payload })).toThrowError(); + expect(() => createRequestForForm({ action, payload })).toThrow(); }); }); }); diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx index be3d3801..02d9f493 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx @@ -265,6 +265,7 @@ describe('HaapiStepperForm', () => { if (field.type === HAAPI_FORM_FIELDS.USERNAME) { formState.set(field, prefilledUsernameValue); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [field]); return ( diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx index ebde03c9..549b24b8 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx @@ -9,6 +9,7 @@ * For further information, please contact Curity AB. */ +import { useCallback, useMemo } from 'react'; import { HAAPI_FORM_FIELDS, VisibleHaapiFormField } from '../../../data-access/types/haapi-form.types'; import { applyRenderInterceptor } from '../../../util/generic-render-interceptor'; import type { @@ -181,9 +182,10 @@ export function HaapiStepperForm({ action, onSubmit, formFieldRenderInterceptor, field => field.type !== HAAPI_FORM_FIELDS.HIDDEN && field.type !== HAAPI_FORM_FIELDS.CONTEXT ); const haapiStepperFormAPI: HaapiStepperFormAPI = { fields: visibleFields, formState }; - const submit = () => { + const submit = useCallback(() => { onSubmit(action, formState.values); - }; + }, [onSubmit, action, formState]); + const formContextValue = useMemo(() => ({ formState, action, submit }), [formState, action, submit]); const formContentElements = applyRenderInterceptor( [haapiStepperFormAPI], @@ -193,7 +195,7 @@ export function HaapiStepperForm({ action, onSubmit, formFieldRenderInterceptor, const formContentElement = formContentElements[0] ?? null; return ( - +
(null); export function useHaapiStepperForm(): HaapiStepperFormContextValue { + // eslint-disable-next-line @eslint-react/no-use-context -- useContext is preferred here over use() to keep explicit null handling const context = useContext(HaapiStepperFormContext); if (!context) { throw new Error('useHaapiStepperForm must be used within a .'); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index 5c7d8417..47baedbd 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -384,7 +384,7 @@ describe('HaapiStepper', () => { await goToNextStep(HAAPI_STEPS.POLLING, { bankId: true }); const startButton = await screen.findByRole('button', { name: 'Start BankID' }); - + act(() => startButton.click()); await waitFor(() => { @@ -497,7 +497,7 @@ describe('HaapiStepper', () => { const secondStep = HAAPI_STEPS.REGISTRATION; const thirdStep = HAAPI_STEPS.COMPLETED_WITH_SUCCESS; let history = await screen.findByTestId('history'); - let historyData = JSON.parse(history.textContent ?? '[]') as HaapiStepperHistoryEntry[]; + let historyData = getHistoryData(history); let previousStepTriggerActionKind = bootstrapLinkAction; expect(historyData).toHaveLength(1); @@ -511,7 +511,7 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(secondStep)); history = screen.getByTestId('history'); - historyData = JSON.parse(history.textContent ?? '[]') as HaapiStepperHistoryEntry[]; + historyData = getHistoryData(history); // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(initialStep).actions[0].kind; @@ -527,7 +527,7 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(thirdStep)); history = screen.getByTestId('history'); - historyData = JSON.parse(history.textContent ?? '[]') as HaapiStepperHistoryEntry[]; + historyData = getHistoryData(history); // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(secondStep).actions[0].kind; @@ -562,7 +562,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent ?? '[]') as unknown as HaapiStepperHistoryEntry[]; + const historyData = getHistoryData(history); // Should have authentication twice - once initially, once after continue same // The continue same step itself is NOT in history, but the updated authentication step is @@ -588,7 +588,7 @@ describe('HaapiStepper', () => { ); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent ?? '[]') as unknown as HaapiStepperHistoryEntry[]; + const historyData = getHistoryData(history); expect(historyData).toHaveLength(2); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -615,7 +615,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent ?? '[]') as unknown as HaapiStepperHistoryEntry[]; + const historyData = getHistoryData(history); expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -642,7 +642,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent ?? '[]') as unknown as HaapiStepperHistoryEntry[]; + const historyData = getHistoryData(history); expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -669,6 +669,7 @@ vi.mock('../../data-access/bootstrap-configuration', () => { const mockThrowErrorToAppErrorBoundary = vi.fn(); vi.mock('../../util/useThrowErrorToAppErrorBoundary', () => ({ + // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix useThrowErrorToAppErrorBoundary: () => mockThrowErrorToAppErrorBoundary, })); @@ -847,3 +848,13 @@ const clickNextStepButton = async () => { act(() => nextStepButton.click()); }; + +function getTextContent(element: HTMLElement): string { + const content: string | null = element.textContent; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- textContent is string|null per DOM types but the linter narrows it to string + return content ?? ''; +} + +function getHistoryData(element: HTMLElement): HaapiStepperHistoryEntry[] { + return JSON.parse(getTextContent(element)) as HaapiStepperHistoryEntry[]; +} diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx index 102140d1..f2a139dd 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx @@ -315,6 +315,7 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { setCurrentStepAndUpdateHistory(nextStepData, action, payload); }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- nextStep is a stable ref via useRefCallback, defined below [configResult, currentStep, history, setCurrentStepAndUpdateHistory] ); @@ -334,18 +335,16 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { useEffect(() => { nextStep(getInitialStepLink()); + return () => cancelPendingOperation(pendingOperation); }, [nextStep]); + const contextValue = useMemo( + () => ({ currentStep, loading, error, nextStep, history }), + [currentStep, loading, error, nextStep, history] + ); + return ( - + {children} ); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx new file mode 100644 index 00000000..a5d79fc7 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2025 Curity AB. All rights reserved. + * + * The contents of this file are the property of Curity AB. + * You may not copy or use this file, in either source code + * or executable form, except in compliance with terms + * set by Curity AB. + * + * For further information, please contact Curity AB. + */ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +import { HaapiStepperErrorNotifier } from './HaapiStepperErrorNotifier'; +import { useHaapiStepper } from './HaapiStepperHook'; +import { HaapiStepperAppError, HaapiStepperError, HaapiStepperInputError } from './haapi-stepper.types'; + +vi.mock('./HaapiStepperHook', () => ({ + useHaapiStepper: vi.fn(), +})); + +const mockUseHaapiStepper = vi.mocked(useHaapiStepper); + +describe('HaapiStepperErrorNotifier', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse(null)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not render when there is no error', () => { + render( + +
content
+
+ ); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + + it('renders the current app error and messages', () => { + const appError = createAppError({ title: APP_ERROR_TITLE, messages: [APP_ERROR_MESSAGE] }); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-toast')).toBeInTheDocument(); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(APP_ERROR_TITLE); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-messages')).toHaveTextContent(APP_ERROR_MESSAGE); + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + + it('dismisses the current error when the dismiss button is clicked', () => { + const appError = createAppError(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + fireEvent.click(screen.getByTestId('haapi-error-haapi-error-notifier-toast-dismiss')); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('auto-dismisses the error after the notification duration', () => { + vi.useFakeTimers(); + const appError = createAppError(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-toast')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('shows new errors after an earlier one has been dismissed', () => { + const firstError = createAppError({ title: FIRST_ERROR_TITLE, messages: [FIRST_ERROR_MESSAGE] }); + const nextError = createAppError({ title: NEXT_ERROR_TITLE, messages: [NEXT_ERROR_MESSAGE] }); + let currentError: HaapiStepperError = { app: firstError }; + + mockUseHaapiStepper.mockImplementation(() => createStepperResponse(currentError)); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(FIRST_ERROR_TITLE); + + fireEvent.click(screen.getByTestId('haapi-error-haapi-error-notifier-toast-dismiss')); + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + + currentError = { app: nextError }; + rerender( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(NEXT_ERROR_TITLE); + }); + + it('renders input errors only when showInputErrorNotifications is true', () => { + const inputError = createInputError(INPUT_ERROR_TITLE); + + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ input: inputError })); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(INPUT_ERROR_TITLE); + + rerender( + +
content
+
+ ); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('renders the latest error when a new error arrives without dismissing', () => { + const firstError = createAppError({ title: FIRST_ERROR_TITLE, messages: [FIRST_ERROR_MESSAGE] }); + const nextError = createAppError({ title: NEXT_ERROR_TITLE, messages: [NEXT_ERROR_MESSAGE] }); + let currentError: HaapiStepperError = { app: firstError }; + + mockUseHaapiStepper.mockImplementation(() => createStepperResponse(currentError)); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(FIRST_ERROR_TITLE); + + currentError = { app: nextError }; + rerender( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(NEXT_ERROR_TITLE); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-messages')).toHaveTextContent(NEXT_ERROR_MESSAGE); + }); + + it('uses a custom errorFormatter for the title', () => { + const appError = createAppError({ title: APP_ERROR_TITLE }); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + const customFormatter = (error: HaapiStepperAppError | HaapiStepperInputError) => `Custom: ${error.title ?? ''}`; + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(`Custom: ${APP_ERROR_TITLE}`); + }); +}); + +function createAppError({ title = DEFAULT_APP_ERROR_TITLE, messages = [DEFAULT_APP_ERROR_MESSAGE] }: { title?: string; messages?: string[] } = {}) { + return { + title, + dataHelpers: { + messages: messages.map((text, index) => ({ id: `app-message-${String(index)}`, text })), + }, + } as unknown as HaapiStepperAppError; +} + +function createInputError(title = INPUT_ERROR_TITLE) { + return { + title, + dataHelpers: { + messages: [], + }, + } as unknown as HaapiStepperInputError; +} + +function createStepperResponse(error: HaapiStepperError | null) { + return { + error, + } as unknown as ReturnType; +} + +const APP_ERROR_TITLE = 'Something went wrong'; +const APP_ERROR_MESSAGE = 'Details go here'; +const FIRST_ERROR_TITLE = 'First error'; +const FIRST_ERROR_MESSAGE = 'first'; +const NEXT_ERROR_TITLE = 'Next error'; +const NEXT_ERROR_MESSAGE = 'second'; +const INPUT_ERROR_TITLE = 'Invalid input'; +const DEFAULT_APP_ERROR_TITLE = 'Something went wrong'; +const DEFAULT_APP_ERROR_MESSAGE = 'Failure'; diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx index 12d0cdd5..aa423d0f 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx @@ -15,40 +15,39 @@ export function HaapiStepperErrorNotifier({ children, showInputErrorNotifications = true, notificationDuration = 10000, - errorFormatter = error => error.title ?? 'An error occurred', + errorFormatter = defaultErrorFormatter, }: HaapiErrorNotifierProps) { const { error } = useHaapiStepper(); - const [notificationError, setNotificationError] = useState(); - const [isNotificationVisible, setIsNotificationVisible] = useState(false); + const currentError = error?.app ?? (showInputErrorNotifications ? error?.input : null); + const [dismissedError, setDismissedError] = useState(null); useEffect(() => { - const notificationError = error?.app ?? (showInputErrorNotifications ? error?.input : null); - - if (notificationError) { - setNotificationError(notificationError); - setIsNotificationVisible(true); + if (!currentError) { + return; + } - const timer = setTimeout(() => { - setIsNotificationVisible(false); - setNotificationError(null); - }, notificationDuration); + const timeout = setTimeout(() => { + setDismissedError(currentError); + }, notificationDuration); - return () => clearTimeout(timer); - } - }, [error?.app, error?.input, showInputErrorNotifications, notificationDuration]); + return () => clearTimeout(timeout); + }, [currentError, notificationDuration]); + const isNotificationVisible = currentError && dismissedError !== currentError; + const notificationMessages = currentError?.dataHelpers.messages ?? []; const handleDismiss = () => { - setIsNotificationVisible(false); + setDismissedError(currentError ?? null); }; + return ( <> - {isNotificationVisible && notificationError && ( + {isNotificationVisible && (

- {errorFormatter(notificationError)} + {errorFormatter(currentError)}

- {notificationError.dataHelpers.messages.length && ( + {notificationMessages.length > 0 && (
- {notificationError.dataHelpers.messages.map(message => ( + {notificationMessages.map(message => (

{message.text}

))}
@@ -77,3 +76,6 @@ export function HaapiStepperErrorNotifier({ ); } + +const defaultErrorFormatter = (error: HaapiStepperAppError | HaapiStepperInputError) => + error.title ?? 'An error occurred'; \ No newline at end of file diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 813b04c0..82f22018 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -416,7 +416,6 @@ describe('HaapiStepperStepUI', () => { ...rest }) => { if (error?.app) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition errorTracker('error_occurred', { type: error.app.type, title: error.app.title, @@ -463,7 +462,6 @@ describe('HaapiStepperStepUI', () => { const selectorAction = screen.getByTestId('selector-action'); const selectorFormActions = within(selectorAction).queryAllByTestId('form-action'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition expect(standaloneFormAction.textContent).toContain('Login Form'); expect(selectorAction.textContent).toContain('Choose Authenticator'); expect(selectorFormActions).toHaveLength(2); @@ -1143,6 +1141,7 @@ describe('HaapiStepperStepUI', () => { if (field.type === HAAPI_FORM_FIELDS.USERNAME) { formState.set(field, prefilledUsernameValue); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [field]); return ( diff --git a/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx b/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx index ae9a16b8..86d34aa9 100644 --- a/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx +++ b/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx @@ -223,7 +223,7 @@ function OnDemandErrorThrowerComponent({ } return ( - ); @@ -260,6 +260,7 @@ function AsyncErrorToErrorBoundaryThrowerComponent({ errorMessage }: { errorMess return (