diff --git a/.changeset/brave-pears-help.md b/.changeset/brave-pears-help.md new file mode 100644 index 00000000..da1ade1c --- /dev/null +++ b/.changeset/brave-pears-help.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +adding support for fields in DROPDOWN, CHECKBOX, COMBOBOX, RADIO, FLOW_LINK. diff --git a/.changeset/config.json b/.changeset/config.json index 8acb5a94..20254db4 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,13 +7,14 @@ } ], "commit": false, - "fixed": [], + "fixed": [["@forgerock/*"]], + "privatePackages": false, "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@forgerock/*", + "@forgerock/device-client", "@forgerock/davinci-app", "@forgerock/davinci-suites", "@forgerock/mock-api-v2" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae0cab0e..1605cad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - run: git branch --track main origin/main - run: pnpm exec nx-cloud record -- nx format:check - - run: pnpm exec nx affected -t build lint test docs e2e-ci + - run: pnpm exec nx affected -t typecheck build lint test docs e2e-ci - uses: codecov/codecov-action@v5 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d2b465f2..a1dde2d4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -76,7 +76,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} - name: Send GitHub Action data to a Slack workflow - if: steps.changesets.outputs.published === 'true' + if: steps.changesets.outputs.published == 'true' uses: slackapi/slack-github-action@v2.0.0 with: payload-delimiter: '_' diff --git a/contributing_docs/testing.md b/contributing_docs/testing.md new file mode 100644 index 00000000..ad82f892 --- /dev/null +++ b/contributing_docs/testing.md @@ -0,0 +1,17 @@ +# Testing + +## Testing Types + +You can test types using vitest. It's important to note that testing types does not actually _run_ a test file. + +When you test types, these are statically analyzed by the compiler. + +Vitest defaults state that all files matching `*.test-d.ts` are considered type-tests + +From the vitest docs: + +``` +Under the hood Vitest calls tsc or vue-tsc, depending on your config, and parses results. Vitest will also print out type errors in your source code, if it finds any. You can disable it with typecheck.ignoreSourceErrors config option. + +Keep in mind that Vitest doesn't run these files, they are only statically analyzed by the compiler. Meaning, that if you use a dynamic name or test.each or test.for, the test name will not be evaluated - it will be displayed as is. +``` diff --git a/e2e/davinci-app/components/flow-link.ts b/e2e/davinci-app/components/flow-link.ts index 757e3012..73d82fdc 100644 --- a/e2e/davinci-app/components/flow-link.ts +++ b/e2e/davinci-app/components/flow-link.ts @@ -1,4 +1,4 @@ -import { FlowCollector, InitFlow } from '@forgerock/davinci-client/types'; +import type { FlowCollector, InitFlow } from '@forgerock/davinci-client/types'; export default function flowLinkComponent( formEl: HTMLFormElement, diff --git a/e2e/davinci-app/components/password.ts b/e2e/davinci-app/components/password.ts index 9e9fec5c..ba601312 100644 --- a/e2e/davinci-app/components/password.ts +++ b/e2e/davinci-app/components/password.ts @@ -1,4 +1,4 @@ -import { PasswordCollector, Updater } from '@forgerock/davinci-client/types'; +import type { PasswordCollector, Updater } from '@forgerock/davinci-client/types'; export default function passwordComponent( formEl: HTMLFormElement, diff --git a/e2e/davinci-app/components/protect.ts b/e2e/davinci-app/components/protect.ts index f88c6b93..c7b398d7 100644 --- a/e2e/davinci-app/components/protect.ts +++ b/e2e/davinci-app/components/protect.ts @@ -1,4 +1,4 @@ -import { TextCollector, Updater } from '@forgerock/davinci-client/types'; +import type { TextCollector, Updater } from '@forgerock/davinci-client/types'; export default function (formEl: HTMLFormElement, collector: TextCollector, updater: Updater) { // create paragraph element with text of "Loading ... " diff --git a/e2e/davinci-app/components/social-login-button.ts b/e2e/davinci-app/components/social-login-button.ts index 593b2dd0..96cca3f9 100644 --- a/e2e/davinci-app/components/social-login-button.ts +++ b/e2e/davinci-app/components/social-login-button.ts @@ -1,4 +1,4 @@ -import { SocialLoginCollector } from '@forgerock/davinci-client/types'; +import type { SocialLoginCollector } from '@forgerock/davinci-client/types'; export default function submitButtonComponent( formEl: HTMLFormElement, diff --git a/e2e/davinci-app/components/submit-button.ts b/e2e/davinci-app/components/submit-button.ts index f082394c..52d1435d 100644 --- a/e2e/davinci-app/components/submit-button.ts +++ b/e2e/davinci-app/components/submit-button.ts @@ -1,4 +1,4 @@ -import { SubmitCollector } from '@forgerock/davinci-client/types'; +import type { SubmitCollector } from '@forgerock/davinci-client/types'; export default function submitButtonComponent(formEl: HTMLFormElement, collector: SubmitCollector) { const button = document.createElement('button'); diff --git a/e2e/davinci-app/components/text.ts b/e2e/davinci-app/components/text.ts index a645d178..a05e27c2 100644 --- a/e2e/davinci-app/components/text.ts +++ b/e2e/davinci-app/components/text.ts @@ -1,4 +1,4 @@ -import { TextCollector, Updater } from '@forgerock/davinci-client/types'; +import type { TextCollector, Updater } from '@forgerock/davinci-client/types'; export default function usernameComponent( formEl: HTMLFormElement, diff --git a/e2e/davinci-app/package.json b/e2e/davinci-app/package.json index def4c2f9..e42344e8 100644 --- a/e2e/davinci-app/package.json +++ b/e2e/davinci-app/package.json @@ -1,14 +1,17 @@ { "name": "@forgerock/davinci-app", + "version": "0.0.0", "description": "Ping DaVinci Client Test App", "type": "module", "private": true, + "nx": { + "tags": ["scope:app"] + }, "dependencies": { "@forgerock/davinci-client": "workspace:*", "@forgerock/javascript-sdk": "4.6.0" }, "devDependencies": {}, - "version": "0.0.0", "scripts": { "build": "nx exec -- vite build --watch false", "serve": "vite dev", diff --git a/e2e/davinci-app/tsconfig.app.json b/e2e/davinci-app/tsconfig.app.json index 4284d543..e9aa43a6 100644 --- a/e2e/davinci-app/tsconfig.app.json +++ b/e2e/davinci-app/tsconfig.app.json @@ -4,6 +4,6 @@ "outDir": "../../dist/out-tsc", "moduleResolution": "Bundler" }, - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], - "include": ["src/**/*.ts"] + "exclude": ["**/*.spec.ts", "**/*.test.ts"], + "include": ["./main.ts", "components/**/*.ts"] } diff --git a/e2e/davinci-app/tsconfig.json b/e2e/davinci-app/tsconfig.json index 2a4c942c..4b6c908d 100644 --- a/e2e/davinci-app/tsconfig.json +++ b/e2e/davinci-app/tsconfig.json @@ -7,6 +7,8 @@ "lib": ["ESNext", "DOM"], "strict": true, "resolveJsonModule": true, + "moduleDetection": "force", + "verbatimModuleSyntax": true, "isolatedModules": true, "esModuleInterop": true, "noUnusedLocals": true, diff --git a/e2e/davinci-suites/package.json b/e2e/davinci-suites/package.json index fec3e1e4..64e2f5cf 100644 --- a/e2e/davinci-suites/package.json +++ b/e2e/davinci-suites/package.json @@ -7,6 +7,9 @@ "main": "src/index.js", "author": "", "license": "ISC", + "nx": { + "implicitDependencies": ["@forgerock/davinci-app", "@forgerock/mock-api-v2"] + }, "repository": { "type": "git", "url": "git+https://github.com/ForgeRock/ping-javascript-sdk.git" diff --git a/nx.json b/nx.json index 41e1f80e..848ff012 100644 --- a/nx.json +++ b/nx.json @@ -12,6 +12,9 @@ "noMarkdown": ["!{projectRoot}/**/*.md"] }, "targetDefaults": { + "typecheck": { + "dependsOn": ["build", "^build"] + }, "docs": { "dependsOn": ["build", "^build", "^docs"], "cache": true, diff --git a/packages/davinci-client/package.json b/packages/davinci-client/package.json index ca0a7c78..48a63d0b 100644 --- a/packages/davinci-client/package.json +++ b/packages/davinci-client/package.json @@ -6,6 +6,10 @@ "typings": "./dist/index.d.ts", "type": "module", "files": ["dist"], + "sideEffects": ["./src/types.js"], + "nx": { + "tags": ["scope:package"] + }, "repository": { "type": "git", "url": "git+https://github.com:ForgeRock/forgerock-javascript-sdk.git", @@ -19,9 +23,6 @@ "@reduxjs/toolkit": "catalog:", "immer": "catalog:" }, - "devDependencies": { - "vitest": "^1.4.0" - }, "exports": { ".": { "import": "./dist/index.js", @@ -31,6 +32,7 @@ }, "scripts": { "build": "nx exec -- vite build", + "serve": "nx exec -- vite dev", "test": "vitest", "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index 05fc8112..881d190d 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -57,7 +57,10 @@ export async function davinci({ config }: { config: DaVinciConfig }) { if (!action.action) { console.error('Missing `argument.action`'); return async function () { - return { error: { message: 'Missing argument.action', type: 'argument_error' } }; + return { + error: { message: 'Missing argument.action', type: 'argument_error' }, + type: 'internal_error', + }; }; } @@ -107,6 +110,7 @@ export async function davinci({ config }: { config: DaVinciConfig }) { return function () { return { error: { message: 'Argument for `collector` has no ID', type: 'argument_error' }, + type: 'internal_error', }; }; } @@ -118,6 +122,7 @@ export async function davinci({ config }: { config: DaVinciConfig }) { return function () { console.error('Collector not found'); return { + type: 'internal_error', error: { message: 'Collector not found', type: 'state_error' }, }; }; @@ -127,6 +132,7 @@ export async function davinci({ config }: { config: DaVinciConfig }) { console.error('Collector is not a SingleValueCollector and cannot be updated'); return function () { return { + type: 'internal_error', error: { message: 'Collector is not a SingleValueCollector and cannot be updated', type: 'state_error', @@ -141,7 +147,10 @@ export async function davinci({ config }: { config: DaVinciConfig }) { return null; } catch (err) { const error = err as Error; - return { error: { message: error.message, type: 'internal_error' } }; + return { + type: 'internal_error', + error: { message: error.message, type: 'internal_error' }, + }; } }; }, diff --git a/packages/davinci-client/src/lib/client.types.test-d.ts b/packages/davinci-client/src/lib/client.types.test-d.ts new file mode 100644 index 00000000..e2566f5a --- /dev/null +++ b/packages/davinci-client/src/lib/client.types.test-d.ts @@ -0,0 +1,188 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { describe, expectTypeOf, it } from 'vitest'; +import type { InitFlow, InternalErrorResponse, Updater } from './client.types.js'; +import type { GenericError } from './error.types.js'; +import type { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; + +describe('Client Types', () => { + it('should allow function returning error', async () => { + // This test isn't excellent but without narrowing inside the function we cant + // narrow the promise type enough without some help on the generic + const withError = async (): Promise<InternalErrorResponse> => { + const result = await Promise.resolve<InternalErrorResponse>({ + error: { message: 'Test error', type: 'argument_error' as const }, + type: 'internal_error' as const, + }); + return result; + }; + + expectTypeOf(withError()).resolves.toMatchTypeOf<InternalErrorResponse>(); + }); + it('should allow function returning node types', () => { + const withErrorNode: InitFlow = async () => ({ + cache: { + key: 'string', + }, + client: { + status: 'error', + }, + error: { + type: 'argument_error', + status: 'failure', + message: 'failed', + }, + httpStatus: 400, + server: { + status: 'error', + }, + status: 'error', + }); + + const withFailureNode: InitFlow = async () => ({ + cache: { + key: '', + }, + client: { + status: 'failure', + }, + error: { + type: 'state_error', + status: 'failure', + message: 'failed', + }, + httpStatus: 404, + server: null, + status: 'failure', + }); + + const withContinueNode: InitFlow = async () => ({ + cache: { + key: 'cachekey', + }, + client: { + action: 'action', + collectors: [], + description: 'the description', + name: 'continue_node_name', + status: 'continue', + }, + error: null, + httpStatus: 200, + server: { + eventName: 'continue_event', + status: 'continue', + }, + status: 'continue', + }); + + const withStartNode: InitFlow = async () => ({ + cache: null, + client: { + status: 'start', + }, + error: null, + server: { + status: 'start', + }, + status: 'start', + }); + + const withSuccessNode: InitFlow = async () => ({ + cache: { + key: 'key', + }, + client: { + authorization: { + code: 'code123412', + state: 'code123213', + }, + status: 'success', + }, + error: null, + httpStatus: 200, + server: { + eventName: 'success_event', + id: 'theid', + interactionId: '213123', + interactionToken: '123213', + status: 'success', + }, + status: 'success', + }); + + // Test return types + // @ts-expect-error - This is a problem because ErrorResponse does not have a discriminator that separates it from FlowNode (see `error` key in both types.) + expectTypeOf(withErrorNode).returns.resolves.toMatchTypeOf<ErrorNode>(); + // @ts-expect-error - This is a problem because ErrorResponse does not have a discriminator that separates it from FlowNode (see `error` key in both types.) + expectTypeOf(withFailureNode).returns.resolves.toMatchTypeOf<FailureNode>(); + // @ts-expect-error - This is a problem because ErrorResponse does not have a discriminator that separates it from FlowNode (see `error` key in both types.) + expectTypeOf(withContinueNode).returns.resolves.toMatchTypeOf<ContinueNode>(); + // @ts-expect-error - This is a problem because ErrorResponse does not have a discriminator that separates it from FlowNode (see `error` key in both types.) + expectTypeOf(withStartNode).returns.resolves.toMatchTypeOf<StartNode>(); + // @ts-expect-error - This is a problem because ErrorResponse does not have a discriminator that separates it from FlowNode (see `error` key in both types.) + expectTypeOf(withSuccessNode).returns.resolves.toMatchTypeOf<SuccessNode>(); + + // Test that all are valid InitFlow types + expectTypeOf(withErrorNode).toMatchTypeOf<InitFlow>(); + expectTypeOf(withFailureNode).toMatchTypeOf<InitFlow>(); + expectTypeOf(withContinueNode).toMatchTypeOf<InitFlow>(); + expectTypeOf(withStartNode).toMatchTypeOf<InitFlow>(); + expectTypeOf(withSuccessNode).toMatchTypeOf<InitFlow>(); + }); + + it('should enforce async function return type', () => { + // @ts-expect-error - Should not allow non-promise return + const invalid: InitFlow = () => ({ + cache: { + key: 'key', + }, + client: { + action: 'continue', + collectors: [], + status: 'continue', + }, + error: null, + httpStatus: 200, + server: { + status: 'continue', + }, + status: 'continue', + }); + + expectTypeOf<InitFlow>().toBeFunction(); + + // @ts-expect-error - Should not allow non-promise return - we expect this to error + expectTypeOf<InitFlow>().returns.toEqualTypeOf<InitFlow>; + }); +}); + +describe('Updater', () => { + it('should accept string value and optional index', () => { + const updater: Updater = (value: string, index?: number) => { + return { + error: { message: 'Invalid value', code: 'INVALID', type: 'state_error' }, + type: 'internal_error', + }; + }; + + expectTypeOf(updater).parameter(0).toBeString(); + expectTypeOf(updater).parameter(1).toBeNullable(); + expectTypeOf(updater).parameter(1).toBeNullable(); + }); + + it('should return error or null', () => { + const withError: Updater = () => ({ + error: { message: 'Invalid value', code: 'INVALID', type: 'state_error' }, + type: 'internal_error', + }); + + const withoutError: Updater = () => null; + + expectTypeOf(withError).returns.toMatchTypeOf<{ error: GenericError } | null>(); + expectTypeOf(withoutError).returns.toMatchTypeOf<{ error: GenericError } | null>(); + + // Test both are valid Updater types + expectTypeOf(withError).toMatchTypeOf<Updater>(); + expectTypeOf(withoutError).toMatchTypeOf<Updater>(); + }); +}); diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 4eea4b10..fa2b4ec1 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -1,8 +1,13 @@ -import { GenericError } from './error.types'; -import { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types'; +import { GenericError } from './error.types.js'; +import { ErrorNode, FailureNode, ContinueNode, StartNode, SuccessNode } from './node.types.js'; -export type InitFlow = - | (() => Promise<{ error: GenericError }>) - | (() => Promise<ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode>); +export type FlowNode = ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; -export type Updater = (value: string, index?: number) => { error: GenericError } | null; +export interface InternalErrorResponse { + error: GenericError; + type: 'internal_error'; +} + +export type InitFlow = () => Promise<FlowNode | InternalErrorResponse>; + +export type Updater = (value: string, index?: number) => InternalErrorResponse | null; diff --git a/packages/davinci-client/src/lib/collector.types.test-d.ts b/packages/davinci-client/src/lib/collector.types.test-d.ts new file mode 100644 index 00000000..02766118 --- /dev/null +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -0,0 +1,355 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { + SingleValueCollectorTypes, + SingleValueCollectorWithValue, + SingleValueCollectorNoValue, + ActionCollectorTypes, + ActionCollectorWithUrl, + ActionCollectorNoUrl, + TextCollector, + PasswordCollector, + FlowCollector, + SocialLoginCollector, + SubmitCollector, + SingleSelectCollector, + MultiValueCollectorWithValue, + MultiSelectCollector, + InferSingleValueCollectorType, + InferMultiValueCollectorType, + InferActionCollectorType, +} from './collector.types.js'; + +describe('Collector Types', () => { + describe('SingleValueCollector Types', () => { + it('should validate TextCollector structure', () => { + expectTypeOf<TextCollector>().toMatchTypeOf<SingleValueCollectorWithValue<'TextCollector'>>(); + expectTypeOf<TextCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'SingleValueCollector'>(); + expectTypeOf<TextCollector>().toHaveProperty('type').toEqualTypeOf<'TextCollector'>(); + expectTypeOf<TextCollector['output']>().toHaveProperty('value'); + expectTypeOf<TextCollector['output']['value']>().toBeString(); + }); + + it('should validate PasswordCollector structure', () => { + expectTypeOf<PasswordCollector>().toMatchTypeOf< + SingleValueCollectorNoValue<'PasswordCollector'> + >(); + expectTypeOf<PasswordCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'SingleValueCollector'>(); + expectTypeOf<PasswordCollector>().toHaveProperty('type'); + expectTypeOf<PasswordCollector['output']>().toEqualTypeOf<{ + key: string; + label: string; + type: string; + }>(); + }); + + it('should validate SingleCollector structure', () => { + expectTypeOf<SingleSelectCollector>().toMatchTypeOf< + SingleValueCollectorWithValue<'SingleSelectCollector'> + >(); + expectTypeOf<SingleSelectCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'SingleValueCollector'>(); + expectTypeOf<SingleSelectCollector>() + .toHaveProperty('type') + .toEqualTypeOf<'SingleSelectCollector'>(); + expectTypeOf<SingleSelectCollector['output']>().toHaveProperty('value'); + }); + + it('should validate MultiSelectCollector structure', () => { + expectTypeOf<MultiSelectCollector>().toMatchTypeOf< + MultiValueCollectorWithValue<'MultiSelectCollector'> + >(); + expectTypeOf<MultiSelectCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'MultiValueCollector'>(); + expectTypeOf<MultiSelectCollector>() + .toHaveProperty('type') + .toEqualTypeOf<'MultiSelectCollector'>(); + expectTypeOf<MultiSelectCollector['output']>().toHaveProperty('value'); + }); + }); + + describe('ActionCollector Types', () => { + it('should validate SocialLoginCollector structure', () => { + expectTypeOf<SocialLoginCollector>().toMatchTypeOf< + ActionCollectorWithUrl<'SocialLoginCollector'> + >(); + expectTypeOf<SocialLoginCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'ActionCollector'>(); + expectTypeOf<SocialLoginCollector>() + .toHaveProperty('type') + .toEqualTypeOf<'SocialLoginCollector'>(); + expectTypeOf<SocialLoginCollector['output']>().toHaveProperty('url'); + }); + + it('should validate FlowCollector structure', () => { + expectTypeOf<FlowCollector>().toMatchTypeOf<ActionCollectorNoUrl<'FlowCollector'>>(); + expectTypeOf<FlowCollector>().toHaveProperty('category').toEqualTypeOf<'ActionCollector'>(); + expectTypeOf<FlowCollector>().toHaveProperty('type').toEqualTypeOf<'FlowCollector'>(); + expectTypeOf<FlowCollector['output']>().not.toHaveProperty('url'); + }); + + it('should validate SubmitCollector structure', () => { + expectTypeOf<SubmitCollector>().toMatchTypeOf<ActionCollectorNoUrl<'SubmitCollector'>>(); + expectTypeOf<SubmitCollector>().toHaveProperty('category').toEqualTypeOf<'ActionCollector'>(); + expectTypeOf<SubmitCollector>().toHaveProperty('type').toEqualTypeOf<'SubmitCollector'>(); + expectTypeOf<SubmitCollector['output']>().not.toHaveProperty('url'); + }); + }); + + describe('Type Inference', () => { + it('should correctly infer SingleValueCollector types', () => { + expectTypeOf<InferSingleValueCollectorType<'TextCollector'>>().toEqualTypeOf<TextCollector>(); + + expectTypeOf< + InferSingleValueCollectorType<'PasswordCollector'> + >().toEqualTypeOf<PasswordCollector>(); + + expectTypeOf< + InferSingleValueCollectorType<'SingleSelectCollector'> + >().toEqualTypeOf<SingleSelectCollector>(); + }); + + it('should handle generic SingleValueCollector type', () => { + type Generic = InferSingleValueCollectorType<'SingleValueCollector'>; + expectTypeOf<Generic>().toMatchTypeOf< + | SingleValueCollectorWithValue<'SingleValueCollector'> + | SingleValueCollectorNoValue<'SingleValueCollector'> + >(); + }); + }); + + describe('Base Type Validations', () => { + it('should validate SingleValueCollectorTypes contains all valid types', () => { + const validTypes: SingleValueCollectorTypes[] = [ + 'TextCollector', + 'PasswordCollector', + 'SingleSelectCollector', + ]; + + // Type assertion to ensure SingleValueCollectorTypes includes all these values + expectTypeOf<SingleValueCollectorTypes>().toEqualTypeOf<(typeof validTypes)[number]>(); + }); + + it('should validate ActionCollectorTypes contains all valid types', () => { + const validTypes: ActionCollectorTypes[] = [ + 'SocialLoginCollector', + 'FlowCollector', + 'SubmitCollector', + ]; + + // Type assertion to ensure ActionCollectorTypes includes all these values + expectTypeOf<ActionCollectorTypes>().toEqualTypeOf<(typeof validTypes)[number]>(); + }); + + it('should validate base type constraints', () => { + // Test SingleValueCollectorWithValue constraints + const withValue: SingleValueCollectorWithValue<'TextCollector'> = { + category: 'SingleValueCollector', + type: 'TextCollector', + error: null, + id: 'test', + name: 'Test', + input: { + key: 'test', + value: 'test', + type: 'string', + }, + output: { + key: 'test', + label: 'Test', + type: 'string', + value: 'test', + }, + }; + expectTypeOf(withValue).toMatchTypeOf<SingleValueCollectorWithValue<'TextCollector'>>(); + + // Test SingleValueCollectorNoValue constraints + const noValue: SingleValueCollectorNoValue<'PasswordCollector'> = { + category: 'SingleValueCollector', + type: 'PasswordCollector', + error: null, + id: 'test', + name: 'Test', + input: { + key: 'test', + value: '', + type: 'string', + }, + output: { + key: 'test', + label: 'Test', + type: 'string', + }, + }; + expectTypeOf(noValue).toMatchTypeOf<SingleValueCollectorNoValue<'PasswordCollector'>>(); + + // Test ActionCollectorWithUrl constraints + const withUrl: ActionCollectorWithUrl<'SocialLoginCollector'> = { + category: 'ActionCollector', + type: 'SocialLoginCollector', + error: null, + id: 'test', + name: 'Test', + output: { + key: 'test', + label: 'Test', + type: 'button', + url: 'https://example.com', + }, + }; + expectTypeOf(withUrl).toMatchTypeOf<ActionCollectorWithUrl<'SocialLoginCollector'>>(); + + // Test ActionCollectorNoUrl constraints + const noUrl: ActionCollectorNoUrl<'SubmitCollector'> = { + category: 'ActionCollector', + type: 'SubmitCollector', + error: null, + id: 'test', + name: 'Test', + output: { + key: 'test', + label: 'Test', + type: 'button', + }, + }; + expectTypeOf(noUrl).toMatchTypeOf<ActionCollectorNoUrl<'SubmitCollector'>>(); + }); + }); + describe('InferSingleValueCollectorFromSingleValueCollectorType', () => { + it('should correctly infer TextCollector Type', () => { + const tCollector: InferSingleValueCollectorType<'TextCollector'> = { + category: 'SingleValueCollector', + error: null, + type: 'TextCollector', + id: '', + name: '', + input: { + key: '', + value: '', + type: '', + }, + output: { + key: '', + label: '', + type: '', + value: '', + }, + }; + + expectTypeOf(tCollector).toMatchTypeOf<TextCollector>(); + }); + it('should correctly infer PasswordCollector Type', () => { + const tCollector: InferSingleValueCollectorType<'PasswordCollector'> = { + category: 'SingleValueCollector', + error: null, + type: 'PasswordCollector', + id: '', + name: '', + input: { + key: '', + value: '', + type: '', + }, + output: { + key: '', + label: '', + type: '', + }, + }; + + expectTypeOf(tCollector).toMatchTypeOf<PasswordCollector>(); + }); + it('should correctly infer SingleValueCollector Type', () => { + const tCollector: InferSingleValueCollectorType<'SingleValueCollector'> = { + category: 'SingleValueCollector', + error: null, + type: 'SingleValueCollector', + id: '', + name: '', + input: { + key: '', + value: '', + type: '', + }, + output: { + key: '', + label: '', + type: '', + value: '', + }, + }; + + expectTypeOf(tCollector).toMatchTypeOf< + SingleValueCollectorWithValue<'SingleValueCollector'> + >(); + }); + it('should correctly infer MultiSelectCollector Type', () => { + const tCollector: InferMultiValueCollectorType<'MultiSelectCollector'> = { + category: 'MultiValueCollector', + error: null, + type: 'MultiSelectCollector', + id: '', + name: '', + input: { + key: '', + value: [''], + type: '', + }, + output: { + key: '', + label: '', + type: '', + value: [''], + options: [{ label: '', value: '' }], + }, + }; + + expectTypeOf(tCollector).toMatchTypeOf<MultiSelectCollector>(); + }); + it('should correctly infer SingleSelectCollector Type', () => { + const tCollector: InferSingleValueCollectorType<'SingleSelectCollector'> = { + category: 'SingleValueCollector', + error: null, + type: 'SingleSelectCollector', + id: '', + name: '', + input: { + key: '', + value: '', + type: '', + }, + output: { + key: '', + label: '', + type: '', + value: '', + options: [{ label: '', value: '' }], + }, + }; + + expectTypeOf(tCollector).toMatchTypeOf<SingleSelectCollector>(); + }); + it('should correctly infer FlowCollector Type', () => { + const tCollector: InferActionCollectorType<'FlowCollector'> = { + category: 'ActionCollector', + error: null, + type: 'FlowCollector', + id: '', + name: '', + output: { + key: '', + label: '', + type: '', + }, + }; + + expectTypeOf(tCollector).toMatchTypeOf<FlowCollector>(); + }); + }); +}); diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index 0b6bc653..0b72e855 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -2,9 +2,15 @@ * @interface SingleValueCollector - Represents a request to collect a single value from the user, like email or password. */ export type SingleValueCollectorTypes = - | 'TextCollector' | 'PasswordCollector' - | 'SingleValueCollector'; + | 'SingleValueCollector' + | 'SingleSelectCollector' + | 'TextCollector'; + +interface SelectorOptions { + label: string; + value: string; +} export interface SingleValueCollectorWithValue<T extends SingleValueCollectorTypes> { category: 'SingleValueCollector'; @@ -25,6 +31,26 @@ export interface SingleValueCollectorWithValue<T extends SingleValueCollectorTyp }; } +export interface SingleSelectCollectorWithValue<T extends SingleValueCollectorTypes> { + category: 'SingleValueCollector'; + error: string | null; + type: T; + id: string; + name: string; + input: { + key: string; + value: string | number | boolean; + type: string; + }; + output: { + key: string; + label: string; + type: string; + value: string; + options: SelectorOptions[]; + }; +} + export interface SingleValueCollectorNoValue<T extends SingleValueCollectorTypes> { category: 'SingleValueCollector'; error: string | null; @@ -43,15 +69,134 @@ export interface SingleValueCollectorNoValue<T extends SingleValueCollectorTypes }; } -export type SingleValueCollectors = - | SingleValueCollectorWithValue<'SingleValueCollector'> - | SingleValueCollectorWithValue<'TextCollector'> - | SingleValueCollectorNoValue<'PasswordCollector'>; +export interface SingleSelectCollectorNoValue<T extends SingleValueCollectorTypes> { + category: 'SingleValueCollector'; + error: string | null; + type: T; + id: string; + name: string; + input: { + key: string; + value: string | number | boolean; + type: string; + }; + output: { + key: string; + label: string; + type: string; + options: SelectorOptions[]; + }; +} + +/** + * Type to help infer the collector based on the collector type + * Used specifically in the returnSingleValueCollector wrapper function. + * When given a type, it can narrow which type it is returning + * + * Note: You can see this type in action in the test file or in the collector.utils file. + */ +export type InferSingleValueCollectorType<T extends SingleValueCollectorTypes> = + T extends 'TextCollector' + ? TextCollector + : T extends 'SingleSelectCollector' + ? SingleSelectCollector + : T extends 'PasswordCollector' + ? PasswordCollector + : /** + * At this point, we have not passed in a collector type + * or we have explicitly passed in 'SingleValueCollector' + * So we can return either a SingleValueCollector with value + * or without a value. + **/ + | SingleValueCollectorWithValue<'SingleValueCollector'> + | SingleValueCollectorNoValue<'SingleValueCollector'>; +/** + * SINGLE-VALUE COLLECTOR TYPES + */ export type SingleValueCollector<T extends SingleValueCollectorTypes> = | SingleValueCollectorWithValue<T> | SingleValueCollectorNoValue<T>; +export type SingleValueCollectors = + | SingleValueCollectorNoValue<'PasswordCollector'> + | SingleSelectCollectorWithValue<'SingleSelectCollector'> + | SingleValueCollectorWithValue<'SingleValueCollector'> + | SingleValueCollectorWithValue<'TextCollector'>; + +export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>; +export type TextCollector = SingleValueCollectorWithValue<'TextCollector'>; +export type SingleSelectCollector = SingleSelectCollectorWithValue<'SingleSelectCollector'>; + +/** + * @interface MultiValueCollector - Represents a request to collect a single value from the user, like email or password. + */ +export type MultiValueCollectorTypes = 'MultiSelectCollector' | 'MultiValueCollector'; + +export interface MultiValueCollectorWithValue<T extends MultiValueCollectorTypes> { + category: 'MultiValueCollector'; + error: string | null; + type: T; + id: string; + name: string; + input: { + key: string; + value: string[]; + type: string; + }; + output: { + key: string; + label: string; + type: string; + value: string[]; + options: SelectorOptions[]; + }; +} + +export interface MultiValueCollectorNoValue<T extends MultiValueCollectorTypes> { + category: 'MultiValueCollector'; + error: string | null; + type: T; + id: string; + name: string; + input: { + key: string; + value: string[]; + type: string; + }; + output: { + key: string; + label: string; + type: string; + value: string[]; + options: SelectorOptions[]; + }; +} + +/** + * Type to help infer the collector based on the collector type + * Used specifically in the returnMultiValueCollector wrapper function. + * When given a type, it can narrow which type it is returning + * + * Note: You can see this type in action in the test file or in the collector.utils file. + */ +export type InferMultiValueCollectorType<T extends MultiValueCollectorTypes> = + T extends 'MultiSelectCollector' + ? MultiValueCollectorWithValue<'MultiSelectCollector'> + : + | MultiValueCollectorWithValue<'MultiValueCollector'> + | MultiValueCollectorNoValue<'MultiValueCollector'>; + +export type MultiValueCollectors = + | MultiValueCollectorWithValue<'MultiValueCollector'> + | MultiValueCollectorWithValue<'MultiSelectCollector'>; + +export type MultiValueCollector<T extends MultiValueCollectorTypes> = + | MultiValueCollectorWithValue<T> + | MultiValueCollectorNoValue<T>; + +export type MultiSelectCollector = MultiValueCollectorWithValue<'MultiSelectCollector'>; + /** * @interface ActionCollector - Represents a user option to perform an action, like submitting a form or choosing another flow. */ @@ -92,6 +237,15 @@ export type ActionCollector<T extends ActionCollectorTypes> = | ActionCollectorNoUrl<T> | ActionCollectorWithUrl<T>; +export type InferActionCollectorType<T extends ActionCollectorTypes> = + T extends 'SocialLoginCollector' + ? SocialLoginCollector + : T extends 'SubmitCollector' + ? SubmitCollector + : T extends 'FlowCollector' + ? FlowCollector + : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; + export type ActionCollectors = | ActionCollectorWithUrl<'SocialLoginCollector'> | ActionCollectorNoUrl<'ActionCollector'> @@ -99,7 +253,5 @@ export type ActionCollectors = | ActionCollectorNoUrl<'SubmitCollector'>; export type FlowCollector = ActionCollectorNoUrl<'FlowCollector'>; -export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'>; -export type TextCollector = SingleValueCollectorWithValue<'TextCollector'>; export type SocialLoginCollector = ActionCollectorWithUrl<'SocialLoginCollector'>; export type SubmitCollector = ActionCollectorNoUrl<'SubmitCollector'>; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index 7197a7a7..b0bc6222 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -1,145 +1,381 @@ import { describe, it, expect } from 'vitest'; +import { + returnActionCollector, + returnFlowCollector, + returnSocialLoginCollector, + returnSubmitCollector, + returnSingleValueCollector, + returnPasswordCollector, + returnTextCollector, + returnSingleSelectCollector, + returnMultiSelectCollector, +} from './collector.utils.js'; +import type { DaVinciField, StandardFieldValue } from './davinci.types.js'; -import { returnActionCollector } from './collector.utils.js'; -import { returnSingleValueCollector } from './collector.utils.js'; +describe('Action Collectors', () => { + describe('returnFlowCollector', () => { + const mockField: DaVinciField = { + key: 'flow-key', + label: 'Flow Label', + type: 'BUTTON', + }; -import type { DaVinciField } from './davinci.types.d.ts'; + it('should create a valid flow collector', () => { + const result = returnFlowCollector(mockField, 1); + expect(result).toEqual({ + category: 'ActionCollector', + error: null, + type: 'FlowCollector', + id: 'flow-key-1', + name: 'flow-key', + output: { + key: mockField.key, + label: mockField.label, + type: mockField.type, + }, + }); + }); -describe('The returnActionCollector function', () => { - const mockField: DaVinciField = { + it('should handle error cases properly', () => { + const invalidField = {} as StandardFieldValue; + const result = returnFlowCollector(invalidField, 1); + expect(result.error).toContain('Key is not found'); + expect(result.error).toContain('Label is not found'); + expect(result.error).toContain('Type is not found'); + }); + }); + + describe('returnSocialLoginCollector', () => { + const mockSocialField: DaVinciField = { + key: 'google-login', + label: 'Continue with Google', + type: 'BUTTON', + links: { + authenticate: { + href: 'https://auth.example.com/google', + }, + }, + }; + + it('should create a valid social login collector with authentication URL', () => { + const result = returnSocialLoginCollector(mockSocialField, 1); + expect(result).toEqual({ + category: 'ActionCollector', + error: null, + type: 'SocialLoginCollector', + id: 'google-login-1', + name: 'google-login', + output: { + key: mockSocialField.key, + label: mockSocialField.label, + type: mockSocialField.type, + url: 'https://auth.example.com/google', + }, + }); + }); + + it('should handle missing authentication URL', () => { + const fieldWithoutUrl: DaVinciField = { + key: 'google-login', + label: 'Continue with Google', + type: 'BUTTON', + }; + // this type could be more comprehensive + // that is why casting as any here works + const result = returnSocialLoginCollector(fieldWithoutUrl, 1); + expect(result.output.url).toBeNull(); + }); + + it('should handle error cases properly', () => { + const invalidField = {} as StandardFieldValue; + const result = returnSocialLoginCollector(invalidField, 1); + expect(result.error).toContain('Key is not found'); + expect(result.type).toBe('SocialLoginCollector'); + }); + }); + + describe('returnSubmitCollector', () => { + const mockField: DaVinciField = { + key: 'submit-key', + label: 'Submit Form', + type: 'SUBMIT_BUTTON', + }; + + it('should create a valid submit collector', () => { + const result = returnSubmitCollector(mockField, 1); + expect(result).toEqual({ + category: 'ActionCollector', + error: null, + type: 'SubmitCollector', + id: 'submit-key-1', + name: 'submit-key', + output: { + key: mockField.key, + label: mockField.label, + type: mockField.type, + }, + }); + }); + + it('should handle error cases properly', () => { + const invalidField = {} as StandardFieldValue; + const result = returnSubmitCollector(invalidField, 1); + expect(result.error).toContain('Key is not found'); + expect(result.type).toBe('SubmitCollector'); + }); + }); + + const mockField: StandardFieldValue = { key: 'testKey', label: 'Test Label', - type: 'TestType', + type: 'TEXT', }; - it('should return a valid FlowCollector with all parameters provided', () => { - const idx = 1; - const collectorType = 'SubmitCollector'; - const result = returnActionCollector(mockField, idx, collectorType); - - expect(result).toEqual({ - category: 'ActionCollector', - error: null, - type: collectorType, - id: `${mockField.key}-${idx}`, - name: mockField.key, - output: { - key: mockField.key, - label: mockField.label, - type: mockField.type, + const socialLoginField: DaVinciField = { + key: 'google-login', + label: 'Login with Google', + type: 'SOCIAL_LOGIN_BUTTON', + links: { + authenticate: { + href: 'https://auth.example.com/google', }, + }, + }; + + describe('returnActionCollector', () => { + it('should return a valid ActionCollector with all parameters provided', () => { + const result = returnActionCollector(mockField, 1, 'ActionCollector'); + expect(result).toEqual({ + category: 'ActionCollector', + error: null, + type: 'ActionCollector', + id: 'testKey-1', + name: 'testKey', + output: { + key: mockField.key, + label: mockField.label, + type: mockField.type, + }, + }); }); - }); - it('should return a valid FlowCollector with all parameters provided', () => { - const idx = 1; - const collectorType = 'FlowCollector'; - const result = returnActionCollector(mockField, idx, collectorType); - - expect(result).toEqual({ - category: 'ActionCollector', - error: null, - type: collectorType, - id: `${mockField.key}-${idx}`, - name: mockField.key, - output: { - key: mockField.key, - label: mockField.label, - type: mockField.type, - }, + it('should return a valid SubmitCollector', () => { + const result = returnActionCollector(mockField, 1, 'SubmitCollector'); + expect(result).toEqual({ + category: 'ActionCollector', + error: null, + type: 'SubmitCollector', + id: 'testKey-1', + name: 'testKey', + output: { + key: mockField.key, + label: mockField.label, + type: mockField.type, + }, + }); }); - }); - it('should default to "ActionCollector" type when collectorType is not provided', () => { - const idx = 2; - const result = returnActionCollector(mockField, idx, 'ActionCollector'); - expect(result.type).toEqual('ActionCollector'); - }); + it('should return a valid FlowCollector', () => { + const result = returnActionCollector(mockField, 1, 'FlowCollector'); + expect(result).toEqual({ + category: 'ActionCollector', + error: null, + type: 'FlowCollector', + id: 'testKey-1', + name: 'testKey', + output: { + key: mockField.key, + label: mockField.label, + type: mockField.type, + }, + }); + }); + + it('creates a social login collector with URL', () => { + const result = returnActionCollector(socialLoginField, 1, 'SocialLoginCollector'); + expect(result).toEqual({ + category: 'ActionCollector', + error: null, + type: 'SocialLoginCollector', + id: 'google-login-1', + name: 'google-login', + output: { + key: 'google-login', + label: 'Login with Google', + type: 'SOCIAL_LOGIN_BUTTON', + url: 'https://auth.example.com/google', + }, + }); + }); - // Just test runtime issues with field - it('should return an error message when field is missing key, label, or type', () => { - const field = {}; - const idx = 3; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-next-line - const result = returnActionCollector(field, idx); - expect(result.error).toBe( - 'Key is not found in the field object. Label is not found in the field object. Type is not found in the field object. ', - ); + it('handles missing authentication URL for social login', () => { + const result = returnActionCollector(mockField, 1, 'SocialLoginCollector'); + expect(result.output.url).toBeNull(); + }); + + it('should return an error message when field is missing key, label, or type', () => { + const field = {}; + const idx = 3; + const result = returnActionCollector(field, idx, 'ActionCollector'); + expect(result.error).toBe( + 'Key is not found in the field object. Label is not found in the field object. Type is not found in the field object. ', + ); + }); }); }); -describe('The returnSingleValueCollector function', () => { +describe('Single Value Collectors', () => { const mockField: DaVinciField = { key: 'testKey', label: 'Test Label', - type: 'TestType', + type: 'TEXT', }; - it('should return a valid SingleValueCollector with all parameters provided', () => { - const idx = 1; - const collectorType = 'PasswordCollector'; - const result = returnSingleValueCollector(mockField, idx, collectorType); - - expect(result).toEqual({ - category: 'SingleValueCollector', - error: null, - type: collectorType, - id: `${mockField.key}-${idx}`, - name: mockField.key, - input: { - key: mockField.key, - value: '', - type: mockField.type, - }, - output: { - key: mockField.key, - label: mockField.label, - type: mockField.type, - }, + describe('returnSingleValueCollector', () => { + it('should return a valid SingleValueCollector with value in output', () => { + const result = returnSingleValueCollector(mockField, 1, 'SingleValueCollector'); + expect(result).toEqual({ + category: 'SingleValueCollector', + error: null, + type: 'SingleValueCollector', + id: 'testKey-1', + name: 'testKey', + input: { + key: mockField.key, + value: '', + type: mockField.type, + }, + output: { + key: mockField.key, + label: mockField.label, + type: mockField.type, + value: '', + }, + }); }); - }); - it('should return a valid SingleValueCollector with all parameters provided', () => { - const idx = 1; - const collectorType = 'TextCollector'; - const result = returnSingleValueCollector(mockField, idx, collectorType); - - expect(result).toEqual({ - category: 'SingleValueCollector', - error: null, - type: collectorType, - id: `${mockField.key}-${idx}`, - name: mockField.key, - input: { - key: mockField.key, - value: '', - type: mockField.type, - }, - output: { - key: mockField.key, - label: mockField.label, - type: mockField.type, - value: '', - }, + it('should return a valid PasswordCollector without value in output', () => { + const result = returnSingleValueCollector(mockField, 1, 'PasswordCollector'); + expect(result).toEqual({ + category: 'SingleValueCollector', + error: null, + type: 'PasswordCollector', + id: 'testKey-1', + name: 'testKey', + input: { + key: mockField.key, + value: '', + type: mockField.type, + }, + output: { + key: mockField.key, + label: mockField.label, + type: mockField.type, + }, + }); + expect(result.output).not.toHaveProperty('value'); }); - }); - it('should default to "SingleValueCollector" type when collectorType is not provided', () => { - const idx = 2; - const result = returnSingleValueCollector(mockField, idx, 'SingleValueCollector'); - expect(result.type).toEqual('SingleValueCollector'); + it('should return an error message when field is missing key, label, or type', () => { + const field = {}; + const idx = 3; + const result = returnSingleValueCollector(field, idx, 'SingleValueCollector'); + expect(result.error).toBe( + 'Key is not found in the field object. Label is not found in the field object. Type is not found in the field object. ', + ); + }); }); - // Just test runtime issues with field - it('should return an error message when field is missing key, label, or type', () => { - const field = {}; - const idx = 3; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-next-line - const result = returnSingleValueCollector(field, idx); - expect(result.error).toBe( - 'Key is not found in the field object. Label is not found in the field object. Type is not found in the field object. ', - ); + describe('Specialized Single Value Collectors', () => { + it('creates a password collector', () => { + const result = returnPasswordCollector(mockField, 1); + expect(result.type).toBe('PasswordCollector'); + expect(result.output).not.toHaveProperty('value'); + }); + + it('creates a text collector', () => { + const result = returnTextCollector(mockField, 1); + expect(result.type).toBe('TextCollector'); + expect(result.output).toHaveProperty('value', ''); + }); + + it('creates a single select collector from radio field type', () => { + const field: DaVinciField = { + type: 'RADIO', + key: 'radio-field', + label: 'Radio', + required: true, + options: [ + { + label: 'radio1', + value: 'radio1', + }, + { + label: 'radio2', + value: 'radio2', + }, + ], + inputType: 'SINGLE_SELECT', + }; + const result = returnSingleSelectCollector(field, 1); + expect(result.type).toBe('SingleSelectCollector'); + expect(result.output).toHaveProperty('value', ''); + }); + + it('creates a single select collector from dropdown field type', () => { + const field: DaVinciField = { + type: 'DROPDOWN', + key: 'dropdown-field', + label: 'Dropdown', + required: true, + options: [ + { + label: 'dropdown1', + value: 'dropdown1', + }, + { + label: 'dropdown2', + value: 'dropdown2', + }, + { + label: 'dropdown3', + value: 'dropdown3', + }, + ], + inputType: 'SINGLE_SELECT', + }; + const result = returnSingleSelectCollector(field, 1); + expect(result.type).toBe('SingleSelectCollector'); + expect(result.output).toHaveProperty('value', ''); + }); + + it('creates a multi-select collector from combobox field type', () => { + const comboField: DaVinciField = { + type: 'COMBOBOX', + key: 'combobox-field', + label: 'Combobox', + required: true, + options: [ + { + label: 'combobox1', + value: 'combobox1', + }, + { + label: 'combobox2', + value: 'combobox2', + }, + ], + inputType: 'MULTI_SELECT', + }; + const result = returnMultiSelectCollector(comboField, 1); + expect(result.type).toBe('MultiSelectCollector'); + expect(result.output).toHaveProperty('value', []); + }); + + it('creates an action collector from flow link field type', () => { + const result = returnFlowCollector(mockField, 1); + expect(result.type).toBe('FlowCollector'); + expect(result.output).not.toHaveProperty('value'); + }); }); }); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 8cdc2418..b05d58c0 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -4,10 +4,18 @@ import type { ActionCollectors, ActionCollectorTypes, - SingleValueCollectors, + InferSingleValueCollectorType, + InferMultiValueCollectorType, SingleValueCollectorTypes, + MultiValueCollectorTypes, + InferActionCollectorType, } from './collector.types'; -import type { DaVinciField } from './davinci.types'; +import type { + DaVinciField, + MultiSelect, + SingleSelect, + StandardFieldValue, +} from './davinci.types.js'; /** * @function returnActionCollector - Creates an ActionCollector object based on the provided field and index. @@ -17,7 +25,7 @@ import type { DaVinciField } from './davinci.types'; * @returns {ActionCollector} The constructed ActionCollector object. */ export function returnActionCollector<CollectorType extends ActionCollectorTypes>( - field: DaVinciField, + field: StandardFieldValue, idx: number, collectorType: CollectorType, ): ActionCollectors { @@ -45,7 +53,7 @@ export function returnActionCollector<CollectorType extends ActionCollectorTypes type: field.type, url: field.links?.['authenticate']?.href || null, }, - }; + } as InferActionCollectorType<CollectorType>; } else { return { category: 'ActionCollector', @@ -58,7 +66,7 @@ export function returnActionCollector<CollectorType extends ActionCollectorTypes label: field.label, type: field.type, }, - }; + } as InferActionCollectorType<CollectorType>; } } @@ -68,8 +76,7 @@ export function returnActionCollector<CollectorType extends ActionCollectorTypes * @param {number} idx - The index of the field in the form * @returns {FlowCollector} - The flow collector object */ - -export function returnFlowCollector(field: DaVinciField, idx: number) { +export function returnFlowCollector(field: StandardFieldValue, idx: number) { return returnActionCollector(field, idx, 'FlowCollector'); } @@ -79,7 +86,7 @@ export function returnFlowCollector(field: DaVinciField, idx: number) { * @param {number} idx - The index of the field in the form * @returns {SocialLoginCollector} - The social login collector object */ -export function returnSocialLoginCollector(field: DaVinciField, idx: number) { +export function returnSocialLoginCollector(field: StandardFieldValue, idx: number) { return returnActionCollector(field, idx, 'SocialLoginCollector'); } @@ -89,7 +96,7 @@ export function returnSocialLoginCollector(field: DaVinciField, idx: number) { * @param {number} idx - The index of the field in the form * @returns {ActionCollector} - The submit collector object */ -export function returnSubmitCollector(field: DaVinciField, idx: number) { +export function returnSubmitCollector(field: StandardFieldValue, idx: number) { return returnActionCollector(field, idx, 'SubmitCollector'); } @@ -101,8 +108,9 @@ export function returnSubmitCollector(field: DaVinciField, idx: number) { * @returns {SingleValueCollector} The constructed SingleValueCollector object. */ export function returnSingleValueCollector< + Field extends DaVinciField, CollectorType extends SingleValueCollectorTypes = 'SingleValueCollector', ->(field: DaVinciField, idx: number, collectorType: CollectorType): SingleValueCollectors { +>(field: Field, idx: number, collectorType: CollectorType) { let error = ''; if (!('key' in field)) { error = `${error}Key is not found in the field object. `; @@ -119,6 +127,32 @@ export function returnSingleValueCollector< category: 'SingleValueCollector', error: error || null, type: collectorType, + id: `${field?.key || field.type}-${idx}`, + name: field.key, + input: { + key: field.key, + value: '', + type: field.type, + }, + output: { + key: field.key, + label: field.label, + type: field.type, + }, + } as InferSingleValueCollectorType<CollectorType>; + } else if (collectorType === 'SingleSelectCollector') { + /** + * Check if options are present in the field object first + * If found, return existing error, which should be '' + * If not found, add additional message to error string + */ + const err = 'options' in field ? error : `${error}Options are not found in the field object. `; + const options = 'options' in field ? field.options : []; // Fallback to ensure type consistency + + return { + category: 'SingleValueCollector', + error: err || null, + type: collectorType, id: `${field.key}-${idx}`, name: field.key, input: { @@ -130,8 +164,10 @@ export function returnSingleValueCollector< key: field.key, label: field.label, type: field.type, + value: '', + options: options, }, - }; + } as InferSingleValueCollectorType<CollectorType>; } else { return { category: 'SingleValueCollector', @@ -150,7 +186,7 @@ export function returnSingleValueCollector< type: field.type, value: '', }, - }; + } as InferSingleValueCollectorType<CollectorType>; } } @@ -160,7 +196,7 @@ export function returnSingleValueCollector< * @param {number} idx - The index to be used in the id of the PasswordCollector. * @returns {PasswordCollector} The constructed PasswordCollector object. */ -export function returnPasswordCollector(field: DaVinciField, idx: number) { +export function returnPasswordCollector(field: StandardFieldValue, idx: number) { return returnSingleValueCollector(field, idx, 'PasswordCollector'); } @@ -170,6 +206,71 @@ export function returnPasswordCollector(field: DaVinciField, idx: number) { * @param {number} idx - The index to be used in the id of the TextCollector. * @returns {TextCollector} The constructed TextCollector object. */ -export function returnTextCollector(field: DaVinciField, idx: number) { +export function returnTextCollector(field: StandardFieldValue, idx: number) { return returnSingleValueCollector(field, idx, 'TextCollector'); } +/** + * @function returnSingleSelectCollector - Creates a SingleCollector object based on the provided field and index. + * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @param {number} idx - The index to be used in the id of the SingleCollector. + * @returns {SingleValueCollector} The constructed SingleCollector object. + */ +export function returnSingleSelectCollector(field: SingleSelect, idx: number) { + return returnSingleValueCollector(field, idx, 'SingleSelectCollector'); +} + +/** + * @function returnMultiValueCollector - Creates a MultiValueCollector object based on the provided field, index, and optional collector type. + * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @param {number} idx - The index to be used in the id of the MultiValueCollector. + * @param {MultiValueCollectorTypes} [collectorType] - Optional type of the MultiValueCollector. + * @returns {MultiValueCollector} The constructed MultiValueCollector object. + */ +export function returnMultiValueCollector< + Field extends MultiSelect, + CollectorType extends MultiValueCollectorTypes = 'MultiValueCollector', +>(field: Field, idx: number, collectorType: CollectorType) { + let error = ''; + if (!('key' in field)) { + error = `${error}Key is not found in the field object. `; + } + if (!('label' in field)) { + error = `${error}Label is not found in the field object. `; + } + if (!('type' in field)) { + error = `${error}Type is not found in the field object. `; + } + if (!('options' in field)) { + error = `${error}Options are not found in the field object. `; + } + + return { + category: 'MultiValueCollector', + error: error || null, + type: collectorType || 'MultiValueCollector', + id: `${field.key}-${idx}`, + name: field.key, + input: { + key: field.key, + value: [], + type: field.type, + }, + output: { + key: field.key, + label: field.label, + type: field.type, + value: [], + options: field.options || [], + }, + } as InferMultiValueCollectorType<CollectorType>; +} + +/** + * @function returnMultiSelectCollector - Creates a DropDownCollector object based on the provided field and index. + * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @param {number} idx - The index to be used in the id of the DropDownCollector. + * @returns {SingleValueCollector} The constructed DropDownCollector object. + */ +export function returnMultiSelectCollector(field: MultiSelect, idx: number) { + return returnMultiValueCollector(field, idx, 'MultiSelectCollector'); +} diff --git a/packages/davinci-client/src/lib/config.types.test-d.ts b/packages/davinci-client/src/lib/config.types.test-d.ts new file mode 100644 index 00000000..dc0528b2 --- /dev/null +++ b/packages/davinci-client/src/lib/config.types.test-d.ts @@ -0,0 +1,267 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { DaVinciConfig, InternalDaVinciConfig } from './config.types.js'; +import type { AsyncConfigOptions } from '@forgerock/javascript-sdk/src/config/interfaces'; +import type { WellknownResponse } from './wellknown.types.js'; + +describe('Config Types', () => { + describe('DaVinciConfig', () => { + it('should extend AsyncConfigOptions', () => { + expectTypeOf<DaVinciConfig>().toMatchTypeOf<AsyncConfigOptions>(); + }); + + it('should have optional responseType', () => { + const config: DaVinciConfig = { + responseType: 'code', + serverConfig: {}, + }; + expectTypeOf(typeof config['responseType']).toBeString(); + expectTypeOf<DaVinciConfig>().toHaveProperty('responseType').toBeNullable(); + }); + + it('should allow AsyncConfigOptions properties', () => { + const config: DaVinciConfig = { + clientId: 'test-client', + scope: 'openid profile', + serverConfig: { + wellknown: 'https://example.com', + timeout: 30000, + }, + redirectUri: 'https://app.example.com/callback', + responseType: 'code', + }; + expectTypeOf(config).toMatchTypeOf<DaVinciConfig>(); + }); + }); + + describe('InternalDaVinciConfig', () => { + it('should extend DaVinciConfig', () => { + expectTypeOf<InternalDaVinciConfig>().toMatchTypeOf<DaVinciConfig>(); + }); + + it('should require wellknownResponse', () => { + const config: InternalDaVinciConfig = { + wellknownResponse: { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + userinfo_endpoint: 'https://example.com/userinfo', + jwks_uri: 'https://example.com/jwks', + revocation_endpoint: 'https://example.com/register', + end_session_endpoint: 'https://example.com/logout', + pushed_authorization_request_endpoint: '', + check_session_iframe: '', + introspection_endpoint: '', + device_authorization_endpoint: '', + claims_parameter_supported: '', + request_parameter_supported: '', + request_uri_parameter_supported: '', + require_pushed_authorization_requests: '', + scopes_supported: [], + response_types_supported: [], + response_modes_supported: [], + grant_types_supported: [], + subject_types_supported: [], + id_token_signing_alg_values_supported: [], + userinfo_signing_alg_values_supported: [], + request_object_signing_alg_values_supported: [], + token_endpoint_auth_methods_supported: [], + token_endpoint_auth_signing_alg_values_supported: [], + claim_types_supported: [], + claims_supported: [], + code_challenge_methods_supported: [], + }, + responseType: 'code', + serverConfig: {}, + }; + expectTypeOf(config).toMatchTypeOf<InternalDaVinciConfig>(); + expectTypeOf<InternalDaVinciConfig>().toHaveProperty('wellknownResponse').toBeObject; + }); + + it('should combine DaVinciConfig and wellknownResponse', () => { + const config: InternalDaVinciConfig = { + // DaVinciConfig properties + clientId: 'test-client', + scope: 'openid profile', + serverConfig: { + wellknown: 'https://example.com', + timeout: 30000, + }, + redirectUri: 'https://app.example.com/callback', + responseType: 'code', + // InternalDaVinciConfig specific property + wellknownResponse: { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + userinfo_endpoint: 'https://example.com/userinfo', + jwks_uri: 'https://example.com/jwks', + revocation_endpoint: 'https://example.com/revoke', + end_session_endpoint: 'https://example.com/logout', + pushed_authorization_request_endpoint: '', + check_session_iframe: '', + introspection_endpoint: '', + device_authorization_endpoint: '', + claims_parameter_supported: '', + request_parameter_supported: '', + request_uri_parameter_supported: '', + require_pushed_authorization_requests: '', + scopes_supported: [], + response_types_supported: [], + response_modes_supported: [], + grant_types_supported: [], + subject_types_supported: [], + id_token_signing_alg_values_supported: [], + userinfo_signing_alg_values_supported: [], + request_object_signing_alg_values_supported: [], + token_endpoint_auth_methods_supported: [], + token_endpoint_auth_signing_alg_values_supported: [], + claim_types_supported: [], + claims_supported: [], + code_challenge_methods_supported: [], + }, + }; + expectTypeOf(config).toMatchTypeOf<InternalDaVinciConfig>(); + }); + }); +}); + +describe('WellknownResponse', () => { + it('should have all required OIDC properties', () => { + const wellknown: WellknownResponse = { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + userinfo_endpoint: 'https://example.com/userinfo', + jwks_uri: 'https://example.com/jwks', + revocation_endpoint: 'https://example.com/revoke', + end_session_endpoint: 'https://example.com/logout', + pushed_authorization_request_endpoint: '', + check_session_iframe: '', + introspection_endpoint: '', + device_authorization_endpoint: '', + claims_parameter_supported: '', + request_parameter_supported: '', + request_uri_parameter_supported: '', + require_pushed_authorization_requests: '', + scopes_supported: [], + response_types_supported: [], + response_modes_supported: [], + grant_types_supported: [], + subject_types_supported: [], + id_token_signing_alg_values_supported: [], + userinfo_signing_alg_values_supported: [], + request_object_signing_alg_values_supported: [], + token_endpoint_auth_methods_supported: [], + token_endpoint_auth_signing_alg_values_supported: [], + claim_types_supported: [], + claims_supported: [], + code_challenge_methods_supported: [], + }; + + expectTypeOf<WellknownResponse>().toHaveProperty('issuer').toBeString(); + expectTypeOf<WellknownResponse>().toHaveProperty('authorization_endpoint').toBeString(); + expectTypeOf<WellknownResponse>().toHaveProperty('token_endpoint').toBeString(); + expectTypeOf<WellknownResponse>().toHaveProperty('userinfo_endpoint').toBeString(); + expectTypeOf<WellknownResponse>().toHaveProperty('jwks_uri').toBeString(); + expectTypeOf<WellknownResponse>().toHaveProperty('revocation_endpoint').toBeString(); + expectTypeOf<WellknownResponse>().toHaveProperty('end_session_endpoint').toBeString(); + + expectTypeOf(wellknown).toMatchTypeOf<WellknownResponse>(); + }); + + it('should allow optional OIDC properties', () => { + const wellknownWithOptionals: WellknownResponse = { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + userinfo_endpoint: 'https://example.com/userinfo', + jwks_uri: 'https://example.com/jwks', + revocation_endpoint: 'https://example.com/revoke', + end_session_endpoint: 'https://example.com/logout', + // Optional properties + scopes_supported: ['openid', 'profile', 'email'], + response_types_supported: ['code', 'token'], + grant_types_supported: ['authorization_code', 'refresh_token'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + pushed_authorization_request_endpoint: '', + check_session_iframe: '', + introspection_endpoint: '', + device_authorization_endpoint: '', + claims_parameter_supported: '', + request_parameter_supported: '', + request_uri_parameter_supported: '', + require_pushed_authorization_requests: '', + response_modes_supported: [], + userinfo_signing_alg_values_supported: [], + request_object_signing_alg_values_supported: [], + token_endpoint_auth_signing_alg_values_supported: [], + claim_types_supported: [], + claims_supported: [], + code_challenge_methods_supported: [], + }; + + // Test optional properties are allowed but not required + expectTypeOf<WellknownResponse>().toHaveProperty('scopes_supported'); + expectTypeOf<WellknownResponse>().toHaveProperty('response_types_supported'); + expectTypeOf<WellknownResponse>().toHaveProperty('grant_types_supported'); + + expectTypeOf(wellknownWithOptionals).toMatchTypeOf<WellknownResponse>(); + }); + + it('should validate property types', () => { + // Test that array properties must contain strings + expectTypeOf<WellknownResponse['scopes_supported']>().toEqualTypeOf<string[]>(); + expectTypeOf<WellknownResponse['response_types_supported']>().toEqualTypeOf<string[]>(); + expectTypeOf<WellknownResponse['grant_types_supported']>().toEqualTypeOf<string[]>(); + expectTypeOf<WellknownResponse['subject_types_supported']>().toEqualTypeOf<string[]>(); + expectTypeOf<WellknownResponse['id_token_signing_alg_values_supported']>().toEqualTypeOf< + string[] + >(); + expectTypeOf<WellknownResponse['token_endpoint_auth_methods_supported']>().toEqualTypeOf< + string[] + >(); + }); + + it('should enforce URL format for endpoint properties', () => { + const wellknown: WellknownResponse = { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + userinfo_endpoint: 'https://example.com/userinfo', + jwks_uri: 'https://example.com/jwks', + revocation_endpoint: 'https://example.com/register', + end_session_endpoint: 'https://example.com/logout', + pushed_authorization_request_endpoint: '', + check_session_iframe: '', + introspection_endpoint: '', + device_authorization_endpoint: '', + claims_parameter_supported: '', + request_parameter_supported: '', + request_uri_parameter_supported: '', + require_pushed_authorization_requests: '', + scopes_supported: [], + response_types_supported: [], + response_modes_supported: [], + grant_types_supported: [], + subject_types_supported: [], + id_token_signing_alg_values_supported: [], + userinfo_signing_alg_values_supported: [], + request_object_signing_alg_values_supported: [], + token_endpoint_auth_methods_supported: [], + token_endpoint_auth_signing_alg_values_supported: [], + claim_types_supported: [], + claims_supported: [], + code_challenge_methods_supported: [], + }; + + // Type assertion to ensure all endpoint properties are strings (URLs) + expectTypeOf(wellknown.authorization_endpoint).toBeString(); + expectTypeOf(wellknown.token_endpoint).toBeString(); + expectTypeOf(wellknown.userinfo_endpoint).toBeString(); + expectTypeOf(wellknown.jwks_uri).toBeString(); + expectTypeOf(wellknown.revocation_endpoint).toBeString(); + expectTypeOf(wellknown.end_session_endpoint).toBeString(); + }); +}); diff --git a/packages/davinci-client/src/lib/config.types.ts b/packages/davinci-client/src/lib/config.types.ts index cd26483b..f7875cb1 100644 --- a/packages/davinci-client/src/lib/config.types.ts +++ b/packages/davinci-client/src/lib/config.types.ts @@ -2,7 +2,7 @@ * Import ConfigOptions type from the JavaScript SDK */ import type { AsyncConfigOptions } from '@forgerock/javascript-sdk/src/config/interfaces'; -import { WellknownResponse } from './wellknown.types'; +import { WellknownResponse } from './wellknown.types.js'; export interface DaVinciConfig extends AsyncConfigOptions { responseType?: string; diff --git a/packages/davinci-client/src/lib/davinci.api.ts b/packages/davinci-client/src/lib/davinci.api.ts index b021cb8f..472cc467 100644 --- a/packages/davinci-client/src/lib/davinci.api.ts +++ b/packages/davinci-client/src/lib/davinci.api.ts @@ -14,7 +14,7 @@ import { handleResponse, transformActionRequest, transformSubmitRequest } from ' * Import the DaVinci types */ import type { RootStateWithNode } from './client.store.utils.js'; -import type { DaVinciCacheEntry, ThrownQueryError } from './davinci.types'; +import type { DaVinciCacheEntry, ThrownQueryError } from './davinci.types.js'; import type { ContinueNode } from './node.types.js'; import type { StartNode } from '../types.js'; diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index fd9781e4..1cd1815c 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -46,18 +46,52 @@ export interface Links { }; } -/** - * Next or Continuation Response DaVinci API - */ - -export interface DaVinciField { - type: string; +export type StandardFieldValue = { + type: + | 'PASSWORD' + | 'TEXT' + | 'SUBMIT_BUTTON' + | 'FLOW_BUTTON' + | 'FLOW_LINK' + | 'SOCIAL_LOGIN_BUTTON' + | 'BUTTON'; key: string; label: string; // Optional properties links?: Links; -} +}; + +export type SingleSelect = { + inputType: 'SINGLE_SELECT'; + key: string; + label: string; + options: { + label: string; + value: string | number; + }[]; + required?: boolean; + type: 'RADIO' | 'DROPDOWN'; +}; + +export type MultiSelect = { + inputType: 'MULTI_SELECT'; + key: string; + label: string; + options: { + label: string; + value: string | number; + }[]; + required?: boolean; + type: 'CHECKBOX' | 'COMBOBOX'; +}; + +export type DaVinciField = StandardFieldValue | SingleSelect | MultiSelect; + +/** + * Next or Continuation Response DaVinci API + */ + export interface DaVinciNextResponse extends DaVinciBaseResponse { // Optional properties _links?: Links; diff --git a/packages/davinci-client/src/lib/davinci.utils.ts b/packages/davinci-client/src/lib/davinci.utils.ts index 93e62971..208a4596 100644 --- a/packages/davinci-client/src/lib/davinci.utils.ts +++ b/packages/davinci-client/src/lib/davinci.utils.ts @@ -3,7 +3,7 @@ */ import type { Dispatch } from '@reduxjs/toolkit'; -import { nodeSlice } from './node.slice'; +import { nodeSlice } from './node.slice.js'; import type { DaVinciCacheEntry, @@ -172,7 +172,6 @@ export function handleResponse(cacheEntry: DaVinciCacheEntry, dispatch: Dispatch */ if (cacheEntry.isSuccess) { const requestId = cacheEntry.requestId; - if ('eventName' in cacheEntry.data && cacheEntry.data.eventName === 'continue') { const data = cacheEntry.data as DaVinciNextResponse; dispatch(nodeSlice.actions.next({ data, requestId, httpStatus: status })); diff --git a/packages/davinci-client/src/lib/error.types.test-d.ts b/packages/davinci-client/src/lib/error.types.test-d.ts new file mode 100644 index 00000000..b658107c --- /dev/null +++ b/packages/davinci-client/src/lib/error.types.test-d.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { describe, expect, it } from 'vitest'; +import type { GenericError } from './error.types.js'; + +describe('GenericError type', () => { + it('should allow valid error objects', () => { + const validErrors: GenericError[] = [ + { + message: 'Something went wrong', + type: 'unknown_error', + }, + { + code: 404, + message: 'Not found', + type: 'network_error', + }, + { + code: 'ERR_001', + message: 'Invalid argument', + type: 'argument_error', + }, + { + message: 'Internal server error', + type: 'internal_error', + }, + { + message: 'Invalid state', + type: 'state_error', + }, + { + message: 'Davinci specific error', + type: 'davinci_error', + }, + ]; + + // TypeScript will validate these assignments + validErrors.forEach((error) => { + expect(error.message).toBeDefined(); + expect(error.type).toBeDefined(); + }); + }); + + // This test is just for TypeScript compilation validation + it('should enforce required properties', () => { + // @ts-expect-error - message is required + const missingMessage: GenericError = { + type: 'unknown_error', + }; + + // @ts-expect-error - type is required + const missingType: GenericError = { + message: 'Error message', + }; + + const invalidType: GenericError = { + message: 'Error message', + // @ts-expect-error - invalid type error below + type: 'invalid_type', + }; + }); +}); diff --git a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts new file mode 100644 index 00000000..92d32438 --- /dev/null +++ b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts @@ -0,0 +1,226 @@ +const obj = { + interactionId: '18fa40b7-0eb8-4a5c-803c-d3f3f807ed46', + companyId: '02fb4743-189a-4bc7-9d6c-a919edfe6447', + connectionId: '8209285e0d2f3fc76bfd23fd10d45e6f', + connectorId: 'pingOneFormsConnector', + id: '65u7m8cm28', + capabilityName: 'customForm', + showContinueButton: false, + form: { + components: { + fields: [ + { + type: 'LABEL', + content: 'Sign On', + }, + { + type: 'LABEL', + content: 'Welcome to Ping Identity', + }, + { + type: 'ERROR_DISPLAY', + }, + { + type: 'TEXT', + key: 'user.username', + label: 'Username', + required: true, + validation: { + regex: '^[^@]+@[^@]+\\.[^@]+$', + errorMessage: 'Must be valid email address', + }, + }, + { + type: 'PASSWORD', + key: 'password', + label: 'Password', + required: true, + }, + { + type: 'SUBMIT_BUTTON', + label: 'Sign On', + key: 'submit', + }, + { + type: 'FLOW_LINK', + key: 'register', + label: 'No account? Register now!', + }, + { + type: 'FLOW_LINK', + key: 'trouble', + label: 'Having trouble signing on?', + }, + { + type: 'DROPDOWN', + key: 'dropdown-field', + label: 'Dropdown', + required: true, + options: [ + { + label: 'dropdown1', + value: 'dropdown1', + }, + { + label: 'dropdown2', + value: 'dropdown2', + }, + { + label: 'dropdown3', + value: 'dropdown3', + }, + ], + inputType: 'SINGLE_SELECT', + }, + { + type: 'COMBOBOX', + key: 'combobox-field', + label: 'Combobox', + required: true, + options: [ + { + label: 'combobox1', + value: 'combobox1', + }, + { + label: 'combobox2', + value: 'combobox2', + }, + ], + inputType: 'MULTI_SELECT', + }, + { + type: 'RADIO', + key: 'radio-field', + label: 'Radio', + required: true, + options: [ + { + label: 'radio1', + value: 'radio1', + }, + { + label: 'radio2', + value: 'radio2', + }, + ], + inputType: 'SINGLE_SELECT', + }, + { + type: 'CHECKBOX', + key: 'checkbox-field', + label: 'Checkbox', + required: true, + options: [ + { + label: 'checkbox1', + value: 'checkbox1', + }, + { + label: 'checkbox2', + value: 'checkbox2', + }, + ], + inputType: 'MULTI_SELECT', + }, + ], + }, + name: 'session main - signon1', + description: 'session main flow - sign on form ', + category: 'CUSTOM_FORM', + }, + theme: 'activeTheme', + formData: { + value: { + 'user.username': '', + password: '', + 'dropdown-field': '', + 'combobox-field': '', + 'radio-field': '', + 'checkbox-field': '', + }, + }, + returnUrl: '', + enableRisk: false, + collectBehavioralData: false, + universalDeviceIdentification: false, + pingidAgent: false, + linkWithP1User: true, + population: 'usePopulationId', + buttonText: 'Submit', + authenticationMethodSource: 'useDefaultMfaPolicy', + nodeTitle: 'Sign On', + nodeDescription: 'Enter username and password', + backgroundColor: '#b7e9deff', + envId: '02fb4743-189a-4bc7-9d6c-a919edfe6447', + region: 'CA', + themeId: 'activeTheme', + formId: 'f0cf83ab-f8f4-4f4a-9260-8f7d27061fa7', + passwordPolicy: { + _links: { + environment: { + href: 'http://10.76.247.190:4140/directory-api/environments/02fb4743-189a-4bc7-9d6c-a919edfe6447', + }, + self: { + href: 'http://10.76.247.190:4140/directory-api/environments/02fb4743-189a-4bc7-9d6c-a919edfe6447/passwordPolicies/39cad7af-3c2f-4672-9c3f-c47e5169e582', + }, + }, + id: '39cad7af-3c2f-4672-9c3f-c47e5169e582', + environment: { + id: '02fb4743-189a-4bc7-9d6c-a919edfe6447', + }, + name: 'Standard', + description: 'A standard policy that incorporates industry best practices', + excludesProfileData: true, + notSimilarToCurrent: true, + excludesCommonlyUsed: true, + maxAgeDays: 182, + minAgeDays: 1, + maxRepeatedCharacters: 2, + minUniqueCharacters: 5, + history: { + count: 6, + retentionDays: 365, + }, + lockout: { + failureCount: 5, + durationSeconds: 900, + }, + length: { + min: 8, + max: 255, + }, + minCharacters: { + '~!@#$%^&*()-_=+[]{}|;:,.<>/?': 1, + '0123456789': 1, + ABCDEFGHIJKLMNOPQRSTUVWXYZ: 1, + abcdefghijklmnopqrstuvwxyz: 1, + }, + populationCount: 1, + createdAt: '2024-01-03T19:50:39.586Z', + updatedAt: '2024-01-03T19:50:39.586Z', + default: true, + }, + isResponseCompatibleWithMobileAndWebSdks: true, + fieldTypes: [ + 'LABEL', + 'ERROR_DISPLAY', + 'TEXT', + 'PASSWORD', + 'RADIO', + 'CHECKBOX', + 'FLOW_LINK', + 'COMBOBOX', + 'DROPDOWN', + 'SUBMIT_BUTTON', + ], + success: true, + interactionToken: + '51bfd3ad179f2f76aa01b759fcc11470a2bd4d99a4b45b72ecaba6e3e422c7dde53987bc2887f0fc590ba22ba7ae1216acce7ef4f213e79075dd73383b63c519db25e2d88840efbf4dc9eda93241d26663d9882f3d738bdbdf06702daa89a630d9bed292d76f5deec5cc0c915738d227ccff9ff8062b15a1b25a8dab8b7f7b96', + startUiSubFlow: true, + _links: { + next: { + href: 'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/davinci/connections/8209285e0d2f3fc76bfd23fd10d45e6f/capabilities/customForm', + }, + }, +}; diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index e1e6a288..75c20446 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { nodeCollectorReducer } from './node.reducer'; -import { SubmitCollector, TextCollector } from './collector.types'; +import { nodeCollectorReducer } from './node.reducer.js'; +import { SubmitCollector, TextCollector } from './collector.types.js'; describe('The node collector reducer', () => { it('should return the initial state', () => { diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index 750f7a70..3b7ddf42 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -14,11 +14,14 @@ import { returnSocialLoginCollector, returnSubmitCollector, returnTextCollector, + returnSingleSelectCollector, + returnMultiSelectCollector, } from './collector.utils.js'; - -import type { DaVinciField } from './davinci.types'; +import type { DaVinciField } from './davinci.types.js'; import { ActionCollector, + MultiSelectCollector, + SingleSelectCollector, FlowCollector, PasswordCollector, SingleValueCollector, @@ -51,6 +54,8 @@ const initialCollectorValues: ( | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> + | SingleSelectCollector + | MultiSelectCollector )[] = []; /** @@ -69,22 +74,29 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build const collectors = action.payload.map((field: DaVinciField, idx: number) => { // Specific Collectors switch (field.type) { - case 'SUBMIT_BUTTON': - return returnSubmitCollector(field, idx); + case 'CHECKBOX': + case 'COMBOBOX': // Intentional fall-through + return returnMultiSelectCollector(field, idx); + case 'DROPDOWN': + case 'RADIO': // Intentional fall-through + return returnSingleSelectCollector(field, idx); case 'FLOW_BUTTON': + case 'FLOW_LINK': // Intentional fall-through return returnFlowCollector(field, idx); - case 'SOCIAL_LOGIN_BUTTON': - return returnSocialLoginCollector(field, idx); case 'PASSWORD': return returnPasswordCollector(field, idx); case 'TEXT': return returnTextCollector(field, idx); + case 'SOCIAL_LOGIN_BUTTON': + return returnSocialLoginCollector(field, idx); + case 'SUBMIT_BUTTON': + return returnSubmitCollector(field, idx); default: // Default is handled below } // Generic Collectors - if (field.type.includes('BUTTON')) { + if (field.type.includes('BUTTON') || field.type.includes('LINK')) { return returnActionCollector(field, idx, 'ActionCollector'); } diff --git a/packages/davinci-client/src/lib/node.slice.test.ts b/packages/davinci-client/src/lib/node.slice.test.ts index ab63d6c2..2046c27d 100644 --- a/packages/davinci-client/src/lib/node.slice.test.ts +++ b/packages/davinci-client/src/lib/node.slice.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { nodeSlice } from './node.slice'; -import { next0 } from './mock-data/davinci.next.mock'; -import { nodeNext0 } from './mock-data/node.next.mock'; -import { success0, success1 } from './mock-data/davinci.success.mock'; -import { nodeSuccess0, nodeSuccess1 } from './mock-data/node.success.mock'; -import { error0a, error2b, error3 } from './mock-data/davinci.error.mock'; +import { nodeSlice } from './node.slice.js'; +import { next0 } from './mock-data/davinci.next.mock.js'; +import { nodeNext0 } from './mock-data/node.next.mock.js'; +import { success0, success1 } from './mock-data/davinci.success.mock.js'; +import { nodeSuccess0, nodeSuccess1 } from './mock-data/node.success.mock.js'; +import { error0a, error2b, error3 } from './mock-data/davinci.error.mock.js'; describe('The node slice reducers', () => { it('should return the initial state', () => { diff --git a/packages/davinci-client/src/lib/node.slice.ts b/packages/davinci-client/src/lib/node.slice.ts index 6dd381d0..fd71d81a 100644 --- a/packages/davinci-client/src/lib/node.slice.ts +++ b/packages/davinci-client/src/lib/node.slice.ts @@ -6,20 +6,20 @@ import { createSlice } from '@reduxjs/toolkit'; /** * Import the needed reducers */ -import { nodeCollectorReducer, updateCollectorValues } from './node.reducer'; +import { nodeCollectorReducer, updateCollectorValues } from './node.reducer.js'; /** * Import the types */ import type { Draft, PayloadAction } from '@reduxjs/toolkit'; -import type { SubmitCollector } from './collector.types'; +import type { SubmitCollector } from './collector.types.js'; import type { DavinciErrorResponse, DaVinciFailureResponse, DaVinciNextResponse, DaVinciSuccessResponse, -} from './davinci.types'; -import type { ContinueNode, SuccessNode, ErrorNode, StartNode, FailureNode } from './node.types'; +} from './davinci.types.js'; +import type { ContinueNode, SuccessNode, ErrorNode, StartNode, FailureNode } from './node.types.js'; /** * The possible statuses for the four types of nodes diff --git a/packages/davinci-client/src/lib/node.types.test-d.ts b/packages/davinci-client/src/lib/node.types.test-d.ts new file mode 100644 index 00000000..d3bdd4c4 --- /dev/null +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -0,0 +1,238 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { describe, expectTypeOf, it } from 'vitest'; +import type { + DaVinciError, + Collectors, + ContinueNode, + ErrorNode, + FailureNode, + StartNode, + SuccessNode, +} from './node.types.js'; +import type { ErrorDetail, Links } from './davinci.types.js'; +import { + ActionCollector, + FlowCollector, + MultiSelectCollector, + PasswordCollector, + SingleSelectCollector, + SingleValueCollector, + SocialLoginCollector, + SubmitCollector, + TextCollector, +} from './collector.types.js'; +// ErrorDetail and Links are used as part of the DaVinciError and server._links types respectively + +describe('Node Types', () => { + describe('DaVinciError', () => { + it('should have required properties', () => { + // @ts-expect-error Variable is used only for type checking + const _error: DaVinciError = { + message: 'Test error', + code: 'TEST_ERROR', + status: 'error', + }; + expectTypeOf<DaVinciError>().toHaveProperty('message'); + expectTypeOf<DaVinciError['message']>().toEqualTypeOf<string>(); + expectTypeOf<DaVinciError>().toHaveProperty('code'); + expectTypeOf<DaVinciError['status']>().toEqualTypeOf<'error' | 'failure' | 'unknown'>(); + }); + + it('should allow nullable properties', () => { + // _errorWithDetails variable is unused but necessary for type checking + const _errorWithDetails: DaVinciError = { + message: 'Test error', + code: 'TEST_ERROR', + status: 'error', + details: [{ message: 'Detail message' } as ErrorDetail], + internalHttpStatus: 400, + type: 'argument_error', + }; + + expectTypeOf<DaVinciError>().toHaveProperty('details').toBeNullable(); + expectTypeOf<DaVinciError>().toHaveProperty('internalHttpStatus').toBeNullable(); + }); + + it('should validate ErrorDetail structure', () => { + const detail: ErrorDetail = { message: 'Test detail' }; + expectTypeOf(detail).toMatchTypeOf<ErrorDetail>(); + }); + + it('should validate Links structure', () => { + const links: Links = { + self: { href: 'https://example.com' }, + next: { href: 'https://example.com/next' }, + }; + expectTypeOf(links).toMatchTypeOf<Links>(); + }); + }); + + describe('Node Types', () => { + it('should validate ContinueNode structure', () => { + // _continueNode variable is unused but necessary for type checking + const _continueNode: ContinueNode = { + cache: { key: 'test-key' }, + client: { + action: 'test-action', + collectors: [], + status: 'continue', + }, + error: null, + httpStatus: 200, + server: { + status: 'continue', + _links: {}, + id: 'test-id', + interactionId: 'test-interaction', + interactionToken: 'test-token', + href: 'test-href', + eventName: 'test-event', + }, + status: 'continue', + }; + + expectTypeOf<ContinueNode>().toHaveProperty('cache').toBeObject(); + expectTypeOf<ContinueNode>().toHaveProperty('client').toBeObject(); + expectTypeOf<ContinueNode>().toHaveProperty('error').toBeNull(); + expectTypeOf<ContinueNode>().toHaveProperty('status').toEqualTypeOf<'continue'>(); + }); + + it('should validate ErrorNode structure', () => { + const errorNode: ErrorNode = { + cache: { key: 'test-key' }, + client: { status: 'error' }, + error: { + message: 'Test error', + code: 'TEST_ERROR', + status: 'error', + type: 'argument_error', + }, + httpStatus: 400, + server: { + status: 'error', + _links: {}, + eventName: 'error-event', + id: 'test-id', + interactionId: 'test-interaction', + interactionToken: 'test-token', + }, + status: 'error', + }; + + expectTypeOf<ErrorNode>().toHaveProperty('error').toMatchTypeOf<DaVinciError>(); + expectTypeOf<ErrorNode>().toHaveProperty('status').toEqualTypeOf<'error'>(); + }); + + it('should validate FailureNode structure', () => { + const failureNode: FailureNode = { + cache: { key: 'test-key' }, + client: { status: 'failure' }, + error: { + message: 'Test failure', + code: 'TEST_FAILURE', + status: 'failure', + type: 'argument_error', + }, + httpStatus: 400, + server: { + status: 'failure', + _links: {}, + eventName: 'failure-event', + href: 'test-href', + id: 'test-id', + interactionId: 'test-interaction', + interactionToken: 'test-token', + }, + status: 'failure', + }; + + expectTypeOf<FailureNode>().toHaveProperty('error').toMatchTypeOf<DaVinciError>(); + expectTypeOf<FailureNode>().toHaveProperty('status').toEqualTypeOf<'failure'>(); + }); + + it('should validate StartNode structure', () => { + const startNode: StartNode = { + cache: null, + client: { status: 'start' }, + error: null, + server: { status: 'start' }, + status: 'start', + }; + + expectTypeOf<StartNode>().toHaveProperty('cache').toBeNull(); + expectTypeOf<StartNode>().toHaveProperty('error').toBeNull(); + expectTypeOf<StartNode>().toHaveProperty('status').toEqualTypeOf<'start'>(); + }); + + it('should validate SuccessNode structure', () => { + const successNode: SuccessNode = { + cache: { key: 'test-key' }, + client: { + status: 'success', + authorization: { + code: 'auth-code', + state: 'auth-state', + }, + }, + error: null, + httpStatus: 200, + server: { + status: 'success', + _links: {}, + eventName: 'success-event', + id: 'test-id', + interactionId: 'test-interaction', + interactionToken: 'test-token', + href: 'test-href', + session: 'test-session', + }, + status: 'success', + }; + + expectTypeOf<SuccessNode>().toHaveProperty('error').toBeNull(); + expectTypeOf<SuccessNode>().toHaveProperty('status').toEqualTypeOf<'success'>(); + expectTypeOf<SuccessNode>().toHaveProperty('client'); + }); + }); + + describe('Collectors Type', () => { + it('should validate Collectors union type', () => { + expectTypeOf<Collectors>().toMatchTypeOf< + | TextCollector + | PasswordCollector + | FlowCollector + | SocialLoginCollector + | SubmitCollector + | ActionCollector<'ActionCollector'> + | SingleValueCollector<'SingleValueCollector'> + | MultiSelectCollector + | SingleSelectCollector + >(); + + // Test that each collector type is part of the union + const collectors: Collectors[] = [ + { + category: 'SingleValueCollector', + type: 'TextCollector', + error: null, + id: 'test', + name: 'Test', + input: { key: 'test', value: '', type: 'string' }, + output: { key: 'test', label: 'Test', type: 'string', value: '' }, + }, + { + category: 'SingleValueCollector', + type: 'PasswordCollector', + error: null, + id: 'test', + name: 'Test', + input: { key: 'test', value: '', type: 'string' }, + output: { key: 'test', label: 'Test', type: 'string' }, + }, + ]; + + expectTypeOf(collectors).toBeArray(); + expectTypeOf(collectors[0]).toMatchTypeOf<Collectors>(); + }); + }); +}); diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 7fab63e6..46933230 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -6,6 +6,8 @@ import type { SubmitCollector, ActionCollector, SingleValueCollector, + SingleSelectCollector, + MultiSelectCollector, } from './collector.types.js'; import type { ErrorDetail, Links } from './davinci.types.js'; import { GenericError } from './error.types.js'; @@ -20,10 +22,12 @@ export type Collectors = | FlowCollector | PasswordCollector | TextCollector + | SingleSelectCollector | SocialLoginCollector | SubmitCollector | ActionCollector<'ActionCollector'> - | SingleValueCollector<'SingleValueCollector'>; + | SingleValueCollector<'SingleValueCollector'> + | MultiSelectCollector; export interface ContinueNode { cache: { diff --git a/packages/davinci-client/src/lib/wellknown.api.ts b/packages/davinci-client/src/lib/wellknown.api.ts index 5a1b62c8..af0c915c 100644 --- a/packages/davinci-client/src/lib/wellknown.api.ts +++ b/packages/davinci-client/src/lib/wellknown.api.ts @@ -1,5 +1,5 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; -import { WellknownResponse } from './wellknown.types'; +import { WellknownResponse } from './wellknown.types.js'; export const wellknownApi = createApi({ reducerPath: 'wellknown', diff --git a/packages/davinci-client/src/types.test-d.ts b/packages/davinci-client/src/types.test-d.ts new file mode 100644 index 00000000..14e90449 --- /dev/null +++ b/packages/davinci-client/src/types.test-d.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { describe, expectTypeOf, it } from 'vitest'; +import type { + NodeStates, + StartNode, + ContinueNode, + ErrorNode, + SuccessNode, + FailureNode, + ActionCollector, + SingleValueCollector, + FlowCollector, + PasswordCollector, + TextCollector, + SocialLoginCollector, + SubmitCollector, +} from './types.js'; +import type * as Types from './types.js'; + +describe('Type exports', () => { + it('should validate all types are exported', () => { + expectTypeOf<typeof Types>().toBeObject(); + // Force type checking of the entire module + type AllExports = typeof Types; + expectTypeOf<AllExports>().not.toBeNever(); + }); +}); + +describe('Type exports', () => { + describe('Node Types', () => { + it('should verify NodeStates union includes all node types', () => { + expectTypeOf<NodeStates>().toEqualTypeOf< + StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode + >(); + }); + + it('should verify StartNode structure', () => { + type ExpectedStartNode = { + cache: null; + client: { status: 'start' }; + error: null; + server: { status: 'start' }; + status: 'start'; + }; + expectTypeOf<StartNode>().toEqualTypeOf<ExpectedStartNode>(); + }); + + it('should verify ContinueNode has required properties', () => { + expectTypeOf<ContinueNode>().toHaveProperty('cache'); + expectTypeOf<ContinueNode>().toHaveProperty('client'); + expectTypeOf<ContinueNode>().toHaveProperty('status'); + }); + }); + + describe('Collector Types', () => { + describe('SingleValueCollector Types', () => { + it('should validate TextCollector structure', () => { + expectTypeOf<TextCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'SingleValueCollector'>(); + expectTypeOf<TextCollector>().toHaveProperty('type').toEqualTypeOf<'TextCollector'>(); + expectTypeOf<TextCollector>().toHaveProperty('input').toBeObject(); + expectTypeOf<TextCollector>().toHaveProperty('output').toBeObject(); + }); + + it('should validate PasswordCollector structure', () => { + expectTypeOf<PasswordCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'SingleValueCollector'>(); + expectTypeOf<PasswordCollector>() + .toHaveProperty('type') + .toEqualTypeOf<'PasswordCollector'>(); + expectTypeOf<PasswordCollector>().toHaveProperty('input').toBeObject(); + expectTypeOf<PasswordCollector>().toHaveProperty('output').toBeObject(); + }); + }); + + describe('ActionCollector Types', () => { + it('should validate SocialLoginCollector structure', () => { + expectTypeOf<SocialLoginCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'ActionCollector'>(); + expectTypeOf<SocialLoginCollector>() + .toHaveProperty('type') + .toEqualTypeOf<'SocialLoginCollector'>(); + expectTypeOf<SocialLoginCollector>().toHaveProperty('output').toBeObject(); + }); + + it('should validate FlowCollector structure', () => { + expectTypeOf<FlowCollector>().toHaveProperty('category').toEqualTypeOf<'ActionCollector'>(); + expectTypeOf<FlowCollector>().toHaveProperty('type').toEqualTypeOf<'FlowCollector'>(); + expectTypeOf<FlowCollector>().toHaveProperty('output').toBeObject(); + }); + + it('should validate SubmitCollector structure', () => { + expectTypeOf<SubmitCollector>() + .toHaveProperty('category') + .toEqualTypeOf<'ActionCollector'>(); + expectTypeOf<SubmitCollector>().toHaveProperty('type').toEqualTypeOf<'SubmitCollector'>(); + expectTypeOf<SubmitCollector>().toHaveProperty('output').toBeObject(); + }); + }); + + describe('Type Constraints', () => { + it('should enforce valid collector types for SingleValueCollector', () => { + // Valid type - should compile + type ValidSingleValue = SingleValueCollector<'TextCollector'>; + expectTypeOf<ValidSingleValue>().toBeObject(); + + // @ts-expect-error - Invalid collector type + type InvalidSingleValue = SingleValueCollector<'InvalidType'>; + }); + + it('should enforce valid collector types for ActionCollector', () => { + // Valid type - should compile + type ValidAction = ActionCollector<'SubmitCollector'>; + expectTypeOf<ValidAction>().toBeObject(); + + // @ts-expect-error - Invalid collector type + type InvalidAction = ActionCollector<'InvalidType'>; + }); + }); + }); +}); diff --git a/packages/davinci-client/tsconfig.spec.json b/packages/davinci-client/tsconfig.spec.json index 0ce2e457..9cac0a9a 100644 --- a/packages/davinci-client/tsconfig.spec.json +++ b/packages/davinci-client/tsconfig.spec.json @@ -1,6 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "module": "NodeNext", + "target": "ES2022", "outDir": "../../dist/out-tsc", "types": [ "vitest/globals", diff --git a/packages/davinci-client/vite.config.ts b/packages/davinci-client/vite.config.ts index ee79dbc7..3188b112 100644 --- a/packages/davinci-client/vite.config.ts +++ b/packages/davinci-client/vite.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; import * as path from 'path'; -import * as pkg from './package.json'; +import pkg from './package.json'; export default defineConfig({ root: __dirname, @@ -52,10 +52,40 @@ export default defineConfig({ }, globals: true, environment: 'jsdom', - include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', 'src/**/*.test-d.ts'], reporters: ['default'], coverage: { - reporter: ['text', 'json', 'html'], + include: ['src/**/*.{js,ts}'], + /** + * You have to extend the vite defaults to include the files you want to exclude from coverage. + */ + exclude: [ + 'src/**/*.mock.{js,ts}', + 'src/**/*.data.{js,ts}', + 'src/**/*.test.{js,ts}', + 'coverage/**', + 'dist/**', + '**/node_modules/**', + '**/[.]**', + 'packages/*/test?(s)/**', + '**/*.d.ts', + '**/virtual:*', + '**/__x00__*', + '**/\x00*', + 'cypress/**', + 'test?(s)/**', + 'test?(-*).?(c|m)[jt]s?(x)', + '**/*{.,-}{test,spec,bench,benchmark}?(-d).?(c|m)[jt]s?(x)', + '**/__tests__/**', + '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', + '**/vitest.{workspace,projects}.[jt]s?(on)', + '**/.{eslint,mocha,prettier}rc.{?(c|m)js,yml}', + ], + reporter: [ + ['text', { skipEmpty: true }], + ['html', { skipEmpty: true }], + ['json', { skipEmpty: true }], + ], enabled: Boolean(process.env['CI']), reportsDirectory: './coverage', provider: 'v8', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d642e4b..16b9d51a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,10 +288,6 @@ importers: immer: specifier: 'catalog:' version: 10.1.1 - devDependencies: - vitest: - specifier: ^1.4.0 - version: 1.5.0(@types/node@22.10.2)(@vitest/ui@1.5.0)(jsdom@22.1.0)(less@4.1.3)(sass@1.75.0)(stylus@0.64.0)(terser@5.33.0) packages/device-client: dependencies: diff --git a/tools/create-package/src/generators/files/vite.config.ts.template b/tools/create-package/src/generators/files/vite.config.ts.template index b8354249..7ebcc2a4 100644 --- a/tools/create-package/src/generators/files/vite.config.ts.template +++ b/tools/create-package/src/generators/files/vite.config.ts.template @@ -9,7 +9,11 @@ export default defineConfig(() => ({ watch: !process.env['CI'], coverage: { enabled: Boolean(process.env['CI']), - reporter: ['text', 'json', 'html'], + reporter: [ + ['text', { skipEmpty: true }], + ['html', { skipEmpty: true }], + ['json', { skipEmpty: true }], + ], reportsDirectory: './coverage', provider: 'v8', }, @@ -21,6 +25,9 @@ export default defineConfig(() => ({ }, }, environment: 'jsdom', - include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + include: [ + 'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}' + 'src/**/*.test-d.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', + ], }, }));