diff --git a/.changeset/real-lights-study.md b/.changeset/real-lights-study.md new file mode 100644 index 00000000..ffdfa765 --- /dev/null +++ b/.changeset/real-lights-study.md @@ -0,0 +1,5 @@ +--- +"redoc": patch +--- + +Release 3.0.0-rc.1. diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index ccb09dc0..a701f976 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -11,7 +11,7 @@ runs: - name: 📦 Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22.17.1 + node-version: 24.11.1 cache: npm - name: ✅ Finished Node setup shell: bash diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml index 33078401..2deec74e 100644 --- a/.github/actions/setup-playwright/action.yml +++ b/.github/actions/setup-playwright/action.yml @@ -13,7 +13,7 @@ runs: id: playwright-version shell: bash run: | - echo "PLAYWRIGHT_VERSION=$(jq -r '.devDependencies["@playwright/test"]' package.json)" >> $GITHUB_OUTPUT + echo "PLAYWRIGHT_VERSION=$(jq -r '.devDependencies[\"@playwright/test\"]' package.json)" >> $GITHUB_OUTPUT - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 name: Check if Playwright browser is cached id: playwright-cache @@ -25,8 +25,7 @@ runs: shell: bash run: | echo "Installing Playwright in root" - echo "Installing @playwright/test@${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} (no-save)" - npm install -D @playwright/test@${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} --no-save + npm install @playwright/test npx playwright install --with-deps - name: ✅ Finished Playwright setup shell: bash diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 178fffb2..49272674 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,8 +1,5 @@ name: Tests e2e -permissions: - contents: read - on: pull_request: branches: @@ -17,7 +14,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22.17 + node-version: 24.11.1 cache: 'npm' - name: Install Dependencies run: npm ci --ignore-scripts --no-fund diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20945176..3ad390a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,9 @@ name: Release -# permissions: -# id-token: write -# contents: read - on: push: branches: - main - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -17,6 +12,10 @@ jobs: release: name: Release runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC + contents: write + pull-requests: write outputs: published: ${{ steps.changesets.outputs.published }} publishedPackages: ${{ steps.changesets.outputs.publishedPackages }} @@ -26,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22.17 + node-version: 24.11.1 cache: 'npm' - name: Install Dependencies run: npm ci @@ -40,11 +39,13 @@ jobs: title: 'chore: 🔖 release new versions' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} dockerhub: needs: [release] if: needs.release.outputs.published == 'true' + permissions: + contents: read + packages: write uses: ./.github/workflows/docker.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} @@ -55,6 +56,8 @@ jobs: needs: [release] if: needs.release.outputs.published == 'true' runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v3 @@ -74,6 +77,8 @@ jobs: runs-on: ubuntu-latest needs: [release, publish-cdn] if: needs.release.outputs.published == 'true' + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v3 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4ac6d0f6..3524b9d9 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -4,8 +4,6 @@ on: pull_request: branches: - main -permissions: - contents: read jobs: build-and-unit: @@ -16,7 +14,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22.17 + node-version: 24.11.1 cache: 'npm' - name: Install Dependencies run: npm ci --ignore-scripts --no-fund diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..ebe9b5d4 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# When this package is built standalone (e.g., in CI), +# ensure dependencies are installed locally, not hoisted to parent workspace +shamefully-hoist=true diff --git a/config/docker/Dockerfile b/config/docker/Dockerfile index a2ce59ff..03679a51 100644 --- a/config/docker/Dockerfile +++ b/config/docker/Dockerfile @@ -5,7 +5,7 @@ # npm i -g http-server # http-server -p 8000 --cors -FROM node:22.20.0-alpine@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af AS build +FROM node:24.11.1-alpine3.22@sha256:2867d550cf9d8bb50059a0fff528741f11a84d985c732e60e19e8e75c7239c43 AS build WORKDIR /build diff --git a/eslint.config.mjs b/eslint.config.mjs index 2f93e47d..23c0b69d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -85,7 +85,8 @@ export default tseslint.config( devDependencies: [ '**/playwright/**', '**/vite.config.ts', - '**/setupTests.ts', + '**/vitest.config.ts', + '**/vitest.setup.ts', '**/__tests__/**/*.{ts,tsx}', '**/benchmarks/*.ts', '**/__mocks__/**/*.{ts,tsx}', diff --git a/examples/index.standalone.html b/examples/index.standalone.html index e56f745c..007b5d33 100644 --- a/examples/index.standalone.html +++ b/examples/index.standalone.html @@ -9,7 +9,7 @@ href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600|Source+Code+Pro:400:700" rel="stylesheet" /> - + \\s*$'; - -// prettier-ignore -export const MDX_COMPONENT_REGEXP = '(?:^ {0,3}<({component})([\\s\\S]*?)>([\\s\\S]*?)' // with children - + '|^ {0,3}<({component})([\\s\\S]*?)(?:/>|\\n{2,}))'; // self-closing - -export const COMPONENT_REGEXP = '(?:' + LEGACY_REGEXP + '|' + MDX_COMPONENT_REGEXP + ')'; - export const DEFAULT_TAG_SLUG = 'other'; export const DEFAULT_WEBHOOKS_TAG_NAME = 'webhooks'; @@ -28,8 +19,6 @@ export const LOADING_STATE = { LOADED: 'LOADED', }; -export const REDOCLY_CONFIG_FILE = 'redocly.yaml'; - export const DISABLE_DEEP_LINK_IF_FIELDS_EXIST = ['title']; export const SEARCH_LIMIT = 100; export const HIGHLIGHTED_TEXT_MAX_LENGTH = 150; diff --git a/src/hooks/useRouter.ts b/src/hooks/useRouter.ts index f387eb91..e269517d 100644 --- a/src/hooks/useRouter.ts +++ b/src/hooks/useRouter.ts @@ -12,6 +12,10 @@ const RouterComponent: Dictionary, RouterType> interface RouterProps { basename?: string; location?: string; + future?: { + v7_startTransition: boolean; + v7_relativeSplatPath: boolean; + }; } export const useRouter = (router: RouterType, basePath: string) => { diff --git a/src/hooks/useTelemetry.ts b/src/hooks/useTelemetry.ts index b36b62b7..de6cde94 100644 --- a/src/hooks/useTelemetry.ts +++ b/src/hooks/useTelemetry.ts @@ -12,6 +12,8 @@ import type { SearchInputResetButtonClickedMessage, SearchOpenedMessage, SearchResultClickedMessage, + ViewSecurityDetailsClickedMessage, + ViewSecurityDetailsClosedMessage, } from '@redocly/redoc-opentelemetry'; import type { Options, OpenAPIParser } from '../services/index.js'; import type { OpenAPISecurityScheme } from '../types/open-api.js'; @@ -81,6 +83,8 @@ export function useTelemetry() { sendSearchResultClickedMessage: () => {}, sendSearchInputResetButtonClickedMessage: () => {}, sendSearchOpenedMessage: () => {}, + sendViewSecurityDetailsClickedMessage: () => {}, + sendViewSecurityDetailsClosedMessage: () => {}, }; } @@ -108,5 +112,11 @@ export function useTelemetry() { redocTelemetry.sendEvent('redoc_ce.search.opened', data), sendSearchResultClickedMessage: (data: EventPayload) => redocTelemetry.sendEvent('redoc_ce.search.result.clicked', data), + sendViewSecurityDetailsClickedMessage: ( + data: EventPayload, + ) => redocTelemetry.sendEvent('redoc_ce.view_security_details.clicked', data), + sendViewSecurityDetailsClosedMessage: ( + data: EventPayload, + ) => redocTelemetry.sendEvent('redoc_ce.view_security_details.closed', data), }; } diff --git a/src/icons/DefaultMappingIcon/DefaultMappingIcon.tsx b/src/icons/DefaultMappingIcon/DefaultMappingIcon.tsx new file mode 100644 index 00000000..744d6be3 --- /dev/null +++ b/src/icons/DefaultMappingIcon/DefaultMappingIcon.tsx @@ -0,0 +1,34 @@ +type IconProps = React.SVGProps; + +import { styled } from '../../styled-components.js'; + +export const Icon = (props: IconProps) => ( + + + + + + + +); + +export const DefaultMappingIcon = styled(Icon).attrs( + () => + ({ + 'data-component-name': 'icons/DefaultMappingIcon', + }) as IconProps, +)` + path { + fill: var(--color-warm-grey-11); + html.dark & { + fill: var(--text-color-secondary); + } + } +`; diff --git a/src/icons/DefaultMappingIcon/index.ts b/src/icons/DefaultMappingIcon/index.ts new file mode 100644 index 00000000..fadaac00 --- /dev/null +++ b/src/icons/DefaultMappingIcon/index.ts @@ -0,0 +1 @@ +export { DefaultMappingIcon } from './DefaultMappingIcon.js'; diff --git a/src/models/__tests__/Callback.test.ts b/src/models/__tests__/Callback.test.ts index 46980121..9bd8e7fe 100644 --- a/src/models/__tests__/Callback.test.ts +++ b/src/models/__tests__/Callback.test.ts @@ -1,12 +1,16 @@ -import { getCallback } from '../callback'; -import { normalizeOptions, OpenAPIParser } from '../../services'; +import { getCallback } from '../callback.js'; +import { normalizeOptions, OpenAPIParser } from '../../services/index.js'; +import spec from './fixtures/callback.json'; const opts = normalizeOptions({}); describe('Models', () => { describe('CallbackModel', () => { - const spec = require('./fixtures/callback.json'); - const parser = new OpenAPIParser(spec, undefined, opts); + const parser = new OpenAPIParser( + spec as unknown as ConstructorParameters[0], + undefined, + opts, + ); test('basic callback details', () => { const callback = getCallback( diff --git a/src/models/__tests__/FieldModel.test.ts b/src/models/__tests__/FieldModel.test.ts index 514e73e1..700ff295 100644 --- a/src/models/__tests__/FieldModel.test.ts +++ b/src/models/__tests__/FieldModel.test.ts @@ -1,14 +1,18 @@ -import type { OperationModel } from '..'; +import type { OperationModel } from '../index.js'; -import { getField } from '../field'; -import { normalizeOptions, OpenAPIParser } from '../../services'; +import { getField } from '../field.js'; +import { normalizeOptions, OpenAPIParser } from '../../services/index.js'; +import spec from './fixtures/fields.json'; const opts = normalizeOptions({}); const deps = { operation: { pointer: 'testFieldModel' } as OperationModel }; describe('Models', () => { describe('FieldModel', () => { - const spec = require('./fixtures/fields.json'); - const parser = new OpenAPIParser(spec, undefined, opts); + const parser = new OpenAPIParser( + spec as unknown as ConstructorParameters[0], + undefined, + opts, + ); test('basic field details', () => { const field = getField( diff --git a/src/models/__tests__/MediaContent.test.ts b/src/models/__tests__/MediaContent.test.ts index 297d4263..1113c374 100644 --- a/src/models/__tests__/MediaContent.test.ts +++ b/src/models/__tests__/MediaContent.test.ts @@ -1,9 +1,9 @@ -import type { OpenAPIDefinition } from '../../types'; -import type { OperationModel } from '..'; +import type { OpenAPIDefinition } from '../../types/index.js'; +import type { OperationModel } from '../index.js'; -import { getMediaContent } from '../mediaContent'; -import { normalizeOptions, OpenAPIParser } from '../../services'; -import { MediaTypes } from '../../constants'; +import { getMediaContent } from '../mediaContent.js'; +import { normalizeOptions, OpenAPIParser } from '../../services/index.js'; +import { MediaTypes } from '../../constants.js'; const options = normalizeOptions({}); const data = { operation: { pointer: 'testMediaContent' } as OperationModel }; @@ -25,7 +25,7 @@ describe('Models', () => { }); test('should work with default arguments', () => { - const consoleError = jest.spyOn(global.console, 'error'); + const consoleError = vi.spyOn(global.console, 'error'); const mediaContentModel = getMediaContent({ parser, info: {}, diff --git a/src/models/__tests__/Operation.test.ts b/src/models/__tests__/Operation.test.ts index 341ebd49..a0e015d4 100644 --- a/src/models/__tests__/Operation.test.ts +++ b/src/models/__tests__/Operation.test.ts @@ -1,20 +1,26 @@ -import type { MediaContentModel, OperationModel, RequestBodyModel } from '../types'; -import type { XPayloadSample } from '../operation'; +import type { MediaContentModel, OperationModel, RequestBodyModel } from '../types.js'; +import type { XPayloadSample } from '../operation.js'; +import type { ExtendedOpenAPIOperation } from '../../services/index.js'; +import type { OpenAPIDefinition, OpenAPIRequestBody, Referenced } from '../../types/index.js'; -import { normalizeOptions, OpenAPIParser } from '../../services'; -import { getOperation } from '../operation'; -import { getRequestBody } from '../request'; -import spec from './fixtures/operation/petstore.json'; -import definition from './fixtures/operation/operationDefinition.json'; -import noRequestDefinition from './fixtures/operation/noRequestOperationDefinition.json'; -import { replaceCircularJson } from './helpers'; +import { normalizeOptions, OpenAPIParser } from '../../services/index.js'; +import { getOperation } from '../operation.js'; +import { getRequestBody } from '../request.js'; +import specFixture from './fixtures/operation/petstore.json'; +import definitionFixture from './fixtures/operation/operationDefinition.json'; +import noRequestDefinitionFixture from './fixtures/operation/noRequestOperationDefinition.json'; +import { replaceCircularJson } from './helpers.js'; const options = normalizeOptions({}); // Add mock at the top with other imports -jest.mock('@redocly/theme', () => ({ - ...jest.requireActual('@redocly/theme'), +vi.mock('@redocly/theme', async () => ({ + ...(await vi.importActual('@redocly/theme')), })); +const spec = specFixture as unknown as OpenAPIDefinition; +const definition = definitionFixture as unknown as ExtendedOpenAPIOperation; +const noRequestDefinition = noRequestDefinitionFixture as unknown as ExtendedOpenAPIOperation; + describe('Models', () => { describe('Operation', () => { const parser = new OpenAPIParser(spec, undefined, options); @@ -27,7 +33,7 @@ describe('Models', () => { operation = getOperation(parser, definition, undefined, options, 'test'); requestBody = getRequestBody({ parser, - infoOrRef: definition.requestBody, + infoOrRef: definition.requestBody as unknown as Referenced, options, operation, isEvent: false, diff --git a/src/models/__tests__/Response.test.ts b/src/models/__tests__/Response.test.ts index a01b6117..7486ea81 100644 --- a/src/models/__tests__/Response.test.ts +++ b/src/models/__tests__/Response.test.ts @@ -1,11 +1,11 @@ import { parseYaml } from '@redocly/openapi-core'; import { outdent } from 'outdent'; -import type { OpenAPIDefinition } from '../../types'; -import type { OperationModel } from '../types'; +import type { OpenAPIDefinition } from '../../types/index.js'; +import type { OperationModel } from '../types.js'; -import { getResponse } from '../response'; -import { normalizeOptions, OpenAPIParser } from '../../services'; +import { getResponse } from '../response.js'; +import { normalizeOptions, OpenAPIParser } from '../../services/index.js'; const opts = normalizeOptions({}); describe('Models', () => { diff --git a/src/models/__tests__/Schema.circular.test.ts b/src/models/__tests__/Schema.circular.test.ts index a8f5bdf3..60713edf 100644 --- a/src/models/__tests__/Schema.circular.test.ts +++ b/src/models/__tests__/Schema.circular.test.ts @@ -1,11 +1,11 @@ -import * as outdent from 'outdent'; +import outdent from 'outdent'; import { parseYaml } from '@redocly/openapi-core'; -import type { OperationModel } from '../types'; +import type { OperationModel } from '../types.js'; -import { getSchema } from '../schema'; -import { normalizeOptions, OpenAPIParser } from '../../services'; -import { circularDetailsPrinter, printSchema } from './helpers'; +import { getSchema } from '../schema.js'; +import { normalizeOptions, OpenAPIParser } from '../../services/index.js'; +import { circularDetailsPrinter, printSchema } from './helpers.js'; const options = normalizeOptions({}); const deps = { operation: { pointer: 'testSchemaCircular' } as OperationModel }; @@ -700,5 +700,47 @@ describe('Models', () => { type: `); }); + test('should use titles in oneOf schemas and should not be recursive', () => { + const spec = parseYaml(outdent` + openapi: 3.1.0 + components: + schemas: + OneOfTitle: + oneOf: + - $ref: "#/components/schemas/Foo" + - $ref: "#/components/schemas/Bar" + Foo: + title: Foo Title + type: object + description: test Foo description + properties: + type: + type: string + Bar: + title: Bar Title + type: object + description: test Bar description + properties: + type: + type: string + `) as any; + + parser = new OpenAPIParser(spec, undefined, options); + const schema = getSchema({ + parser, + schemaOrRef: spec.components.schemas.OneOfTitle, + pointer: '#/components/schemas/OneOfTitle', + options, + deps, + }); + + expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(` + oneOf + Foo Title -> + type: + Bar Title -> + type: + `); + }); }); }); diff --git a/src/models/__tests__/Schema.test.ts b/src/models/__tests__/Schema.test.ts index c90b8be6..0e72b1d1 100644 --- a/src/models/__tests__/Schema.test.ts +++ b/src/models/__tests__/Schema.test.ts @@ -1,14 +1,34 @@ -/* eslint @typescript-eslint/no-var-requires: 0 */ import { parseYaml } from '@redocly/openapi-core'; import { outdent } from 'outdent'; -import type { OperationModel, SchemaModel } from '../types'; +import type { OperationModel, SchemaModel } from '../types.js'; -import { getMediaType } from '../mediaType'; -import { getField } from '../field'; -import { normalizeOptions, OpenAPIParser } from '../../services'; -import { enumDetailsPrinter, printSchema } from './helpers'; -import { getSchema } from '../schema'; +import { getMediaType } from '../mediaType.js'; +import { getField } from '../field.js'; +import { normalizeOptions, OpenAPIParser } from '../../services/index.js'; +import { enumDetailsPrinter, printSchema } from './helpers.js'; +import { getSchema } from '../schema.js'; + +// Fixture imports +import nestedEnumDescriptionSpec from './fixtures/nestedEnumDescroptionSample.json'; +import discriminatorSpec from './fixtures/discriminator.json'; +import discriminatorWithDefaultMappingSpec from './fixtures/discriminator-with-default-mapping.json'; +import discriminatorDefaultOnlySpec from './fixtures/discriminator-default-only.json'; +import discriminatorOrderedWithDefaultSpec from './fixtures/discriminator-ordered-with-default.json'; +import oneOfTitlesSpec from './fixtures/oneOfTitles.json'; +import conditionalSchemaSpec from './fixtures/3.1/conditionalSchema.json'; +import conditionalFieldSpec from './fixtures/3.1/conditionalField.json'; +import patternPropertiesSpec from './fixtures/3.1/patternProperties.json'; +import prefixItemsSpec from './fixtures/3.1/prefixItems.json'; +import arrayItemsSpec from './fixtures/arrayItems.json'; +import fieldsSpec from './fixtures/fields.json'; +import type { OpenAPIDefinition, OpenAPISchema } from '../../types/index.js'; + +// Map for test.each fixtures +const fixtureMap: Record = { + './fixtures/3.1/prefixItems.json': prefixItemsSpec, + './fixtures/arrayItems.json': arrayItemsSpec, +}; const options = normalizeOptions({}); const deps = { operation: { pointer: 'testSchema' } as OperationModel }; @@ -17,9 +37,9 @@ describe('Models', () => { let parser; test('parsing nested x-enumDescription', () => { - const spec = require('./fixtures/nestedEnumDescroptionSample.json'); + const spec = nestedEnumDescriptionSpec as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, options); - const testSchema = spec.components.schemas.Test; + const testSchema = spec.components?.schemas?.Test as OpenAPISchema; const schemaModel = getSchema({ parser, schemaOrRef: testSchema, @@ -29,12 +49,12 @@ describe('Models', () => { }); expect(schemaModel['x-enumDescriptions']).toStrictEqual( - testSchema.items['x-enumDescriptions'], + testSchema?.items?.['x-enumDescriptions'], ); }); test('discriminator with one field', () => { - const spec = require('./fixtures/discriminator.json'); + const spec = discriminatorSpec; parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, @@ -47,12 +67,64 @@ describe('Models', () => { expect(schema.discriminatorProp).toEqual('type'); }); - test('oneOf/allOf titles', () => { - const spec = require('./fixtures/oneOfTitles.json'); + test('discriminator with defaultMapping adds it as last item', () => { + const spec = discriminatorWithDefaultMappingSpec; parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Test, + schemaOrRef: spec.components.schemas.Pet, + pointer: '#/components/schemas/Pet', + options, + deps, + }); + expect(schema.oneOf).toHaveLength(3); + expect(schema.oneOf?.[0].isDefaultMapping).toBe(false); + expect(schema.oneOf?.[0].title).toBe('cat'); + expect(schema.oneOf?.[1].isDefaultMapping).toBe(false); + expect(schema.oneOf?.[1].title).toBe('dog'); + expect(schema.oneOf?.[2].isDefaultMapping).toBe(true); + expect(schema.oneOf?.[2].title).toBe('Default mapping'); + }); + + test('discriminator with only defaultMapping (no mapping)', () => { + const spec = discriminatorDefaultOnlySpec; + parser = new OpenAPIParser(spec, undefined, options); + const schema = getSchema({ + parser, + schemaOrRef: spec.components.schemas.Animal, + pointer: '#/components/schemas/Animal', + options, + deps, + }); + expect(schema.oneOf).toHaveLength(1); + expect(schema.oneOf?.[0].isDefaultMapping).toBe(true); + expect(schema.oneOf?.[0].title).toBe('Default mapping'); + }); + + test('discriminator defaultMapping order preserved after sorting', () => { + const spec = discriminatorOrderedWithDefaultSpec; + parser = new OpenAPIParser(spec, undefined, options); + const schema = getSchema({ + parser, + schemaOrRef: spec.components.schemas.Vehicle, + pointer: '#/components/schemas/Vehicle', + options, + deps, + }); + expect(schema.oneOf).toHaveLength(4); + expect(schema.oneOf?.[0].title).toBe('truck'); + expect(schema.oneOf?.[1].title).toBe('bike'); + expect(schema.oneOf?.[2].title).toBe('car'); + expect(schema.oneOf?.[3].title).toBe('Default mapping'); + expect(schema.oneOf?.[3].isDefaultMapping).toBe(true); + }); + + test('oneOf/allOf titles', () => { + const spec = oneOfTitlesSpec as unknown as OpenAPIDefinition; + parser = new OpenAPIParser(spec, undefined, options); + const schema = getSchema({ + parser, + schemaOrRef: spec.components?.schemas?.Test as OpenAPISchema, pointer: '', options, deps, @@ -81,14 +153,14 @@ describe('Models', () => { }); test('oneOf/allOf titles with hideSchemaTitles', () => { - const spec = require('./fixtures/oneOfTitles.json'); + const spec = oneOfTitlesSpec as unknown as OpenAPIDefinition; const options = normalizeOptions({ hideSchemaTitles: true, }); parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Test, + schemaOrRef: spec.components?.schemas?.Test as OpenAPISchema, pointer: '', options, deps, @@ -113,11 +185,11 @@ describe('Models', () => { }); test('oneOf/allOf schema complex displayType', () => { - const spec = require('./fixtures/oneOfTitles.json'); + const spec = oneOfTitlesSpec as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.WithArray, + schemaOrRef: spec.components?.schemas?.WithArray as OpenAPISchema, pointer: '', options, deps, @@ -126,50 +198,115 @@ describe('Models', () => { expect(schema.displayType).toBe('(Array of strings or numbers) or string'); }); - test('oneOf titles should not be overridden by parent schema title', () => { - const spec = { - openapi: '3.1.0', - components: { - schemas: { - Parent: { - title: 'Parent', - type: 'object', - oneOf: [ - { $ref: '#/components/schemas/Child1' }, - { $ref: '#/components/schemas/Child2' }, - ], - }, - Child1: { - title: 'Child1', - type: 'object', - }, - Child2: { - type: 'object', - }, - }, - }, - }; + test('oneOf/anyOf with pure $ref should NOT be marked as circular', () => { + const spec = parseYaml(outdent` + openapi: 3.1.0 + components: + schemas: + ContactObject: + type: object + properties: + firstName: + type: string + lastName: + type: string + Customer: + type: object + properties: + primaryAddressOneOf: + description: Customer's primary address + oneOf: + - $ref: '#/components/schemas/ContactObject' + - type: 'null' + primaryAddressAnyOf: + description: Customer's primary address + anyOf: + - $ref: '#/components/schemas/ContactObject' + - type: 'null' + `) as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Parent, - pointer: '', + schemaOrRef: spec.components?.schemas?.Customer as OpenAPISchema, + pointer: '#/components/schemas/Customer', options, deps, }); - expect(schema.oneOf?.[0].rawSchema.allOf[0].title).toBe('Child1'); - expect(schema.oneOf?.[1].rawSchema.allOf[0].title).toBe('Child2'); + expect(schema.isCircular).toBe(false); + + const oneOfField = schema.fields?.find((f) => f.name === 'primaryAddressOneOf'); + expect(oneOfField).toBeDefined(); + expect(oneOfField?.schema.oneOf).toBeDefined(); + expect(oneOfField?.schema.oneOf?.length).toBe(2); + + oneOfField?.schema.oneOf?.forEach((variant) => { + expect(variant.isCircular).toBe(false); + }); + + const anyOfField = schema.fields?.find((f) => f.name === 'primaryAddressAnyOf'); + expect(anyOfField).toBeDefined(); + expect(anyOfField?.schema.oneOf).toBeDefined(); + expect(anyOfField?.schema.oneOf?.length).toBe(2); + + anyOfField?.schema.oneOf?.forEach((variant) => { + expect(variant.isCircular).toBe(false); + }); + }); + + test('oneOf/anyOf with circular reference should be detected', () => { + const spec = parseYaml(outdent` + openapi: 3.1.0 + components: + schemas: + TreeNode: + type: object + properties: + value: + type: string + nextOneOf: + oneOf: + - $ref: '#/components/schemas/TreeNode' + - type: 'null' + nextAnyOf: + anyOf: + - $ref: '#/components/schemas/TreeNode' + - type: 'null' + `) as unknown as OpenAPIDefinition; + + parser = new OpenAPIParser(spec, undefined, options); + + const schema = getSchema({ + parser, + schemaOrRef: spec.components?.schemas?.TreeNode as OpenAPISchema, + pointer: '#/components/schemas/TreeNode', + options, + deps, + }); + + const oneOfField = schema.fields?.find((f) => f.name === 'nextOneOf'); + expect(oneOfField).toBeDefined(); + expect(oneOfField?.schema.oneOf).toBeDefined(); + + const oneOfCircularVariant = oneOfField?.schema.oneOf?.find((v) => v.isCircular); + expect(oneOfCircularVariant).toBeDefined(); + + const anyOfField = schema.fields?.find((f) => f.name === 'nextAnyOf'); + expect(anyOfField).toBeDefined(); + expect(anyOfField?.schema.oneOf).toBeDefined(); + + const anyOfCircularVariant = anyOfField?.schema.oneOf?.find((v) => v.isCircular); + expect(anyOfCircularVariant).toBeDefined(); }); test('schemaDefinition should resolve schema with conditional operators', () => { - const spec = require('./fixtures/3.1/conditionalSchema.json'); + const spec = conditionalSchemaSpec as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Test, + schemaOrRef: spec.components?.schemas?.Test as OpenAPISchema, pointer: '', options, deps, @@ -184,11 +321,11 @@ describe('Models', () => { }); test('schemaDefinition should resolve field with conditional operators', () => { - const spec = require('./fixtures/3.1/conditionalField.json'); + const spec = conditionalFieldSpec as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Test, + schemaOrRef: spec.components?.schemas?.Test as OpenAPISchema, pointer: '', options, deps, @@ -203,16 +340,16 @@ describe('Models', () => { }); test('schemaDefinition should resolve patternProperties', () => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); - const spec = require('./fixtures/3.1/patternProperties.json'); + const spec = patternPropertiesSpec as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, { ...options, sortRequiredPropsFirst: true, }); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Patterns, + schemaOrRef: spec.components?.schemas?.Patterns as OpenAPISchema, pointer: '', options: { ...options, @@ -250,11 +387,11 @@ describe('Models', () => { test.each(eachArray)( 'schemaDefinition should resolve prefixItems without additional items', (specFixture) => { - const spec = require(specFixture); + const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition; const parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Case1, + schemaOrRef: spec.components?.schemas?.Case1 as OpenAPISchema, pointer: '', options, deps, @@ -272,11 +409,11 @@ describe('Models', () => { test.each(eachArray)( 'schemaDefinition should resolve prefixItems with additional items', (specFixture) => { - const spec = require(specFixture); + const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition; const parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Case2, + schemaOrRef: spec.components?.schemas?.Case2 as OpenAPISchema, pointer: '', options, deps, @@ -295,11 +432,11 @@ describe('Models', () => { test.each(eachArray)( 'schemaDefinition should resolve prefixItems with additional items with $ref', (specFixture) => { - const spec = require(specFixture); + const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition; const parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Case3, + schemaOrRef: spec.components?.schemas?.Case3 as OpenAPISchema, pointer: '', options, deps, @@ -319,11 +456,11 @@ describe('Models', () => { test.each(eachArray)( 'schemaDefinition should resolve prefixItems with additional schema items', (specFixture) => { - const spec = require(specFixture); + const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition; const parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Case4, + schemaOrRef: spec.components?.schemas?.Case4 as OpenAPISchema, pointer: '', options, deps, @@ -342,11 +479,11 @@ describe('Models', () => { test.each(eachArray)( 'schemaDefinition should resolve prefixItems with additional array items', (specFixture) => { - const spec = require(specFixture); + const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition; const parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Case5, + schemaOrRef: spec.components?.schemas?.Case5 as OpenAPISchema, pointer: '', options, deps, @@ -370,11 +507,11 @@ describe('Models', () => { test.each(eachArray)( 'schemaDefinition should resolve prefixItems with additional array items', (specFixture) => { - const spec = require(specFixture); + const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition; const parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Case6, + schemaOrRef: spec.components?.schemas?.Case6 as OpenAPISchema, pointer: '', options, deps, @@ -392,11 +529,11 @@ describe('Models', () => { test.each(eachArray)( 'schemaDefinition should resolve items with boolean type', (specFixture) => { - const spec = require(specFixture); + const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition; const parser = new OpenAPIParser(spec, undefined, options); const schema = getSchema({ parser, - schemaOrRef: spec.components.schemas.Case7, + schemaOrRef: spec.components?.schemas?.Case7 as OpenAPISchema, pointer: '', options, deps, @@ -412,7 +549,7 @@ describe('Models', () => { }); test('content example should be override the example provided by the schema', () => { - const spec = require('./fixtures/fields.json'); + const spec = fieldsSpec as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, options); const field = getField( parser, @@ -829,5 +966,72 @@ describe('Models', () => { maxItems: " `); }); + + test('oneOf child titles should not be overridden by parent schema title via $ref', () => { + const spec = { + openapi: '3.1.0', + components: { + schemas: { + DocumentMetadata: { + type: 'object', + properties: { + entityMetadata: { + $ref: '#/components/schemas/EntityMetadata', + }, + }, + }, + EntityMetadata: { + title: 'EntityMetadata', + description: 'Metadata for associated entity of document.', + type: 'object', + oneOf: [ + { $ref: '#/components/schemas/PnrMetadataWrapper' }, + { $ref: '#/components/schemas/EventMetadataWrapper' }, + ], + }, + PnrMetadataWrapper: { + type: 'object', + title: 'PnrMetadataWrapper', + properties: { + pnrMetadata: { + type: 'string', + }, + }, + }, + EventMetadataWrapper: { + type: 'object', + title: 'EventMetadataWrapper', + properties: { + eventMetadata: { + type: 'string', + }, + }, + }, + }, + }, + } as unknown as OpenAPIDefinition; + + parser = new OpenAPIParser(spec, undefined, options); + + const documentSchema = getSchema({ + parser, + schemaOrRef: spec.components?.schemas?.DocumentMetadata as OpenAPISchema, + pointer: '#/components/schemas/DocumentMetadata', + options, + deps, + }); + + const entityMetadataField = documentSchema.fields?.find((f) => f.name === 'entityMetadata'); + + expect(entityMetadataField).toBeDefined(); + expect(entityMetadataField?.schema.oneOf).toBeDefined(); + expect(entityMetadataField?.schema.oneOf).toHaveLength(2); + + expect(entityMetadataField?.schema.oneOf?.[0].title).toBe('PnrMetadataWrapper'); + expect(entityMetadataField?.schema.oneOf?.[1].title).toBe('EventMetadataWrapper'); + + expect(entityMetadataField?.schema.oneOf?.[0].title).not.toBe('EntityMetadata'); + expect(entityMetadataField?.schema.oneOf?.[1].title).not.toBe('EntityMetadata'); + }); }); }); diff --git a/src/models/__tests__/__snapshots__/Schema.test.ts.snap b/src/models/__tests__/__snapshots__/Schema.test.ts.snap index d0911ff1..0b8b8f3c 100644 --- a/src/models/__tests__/__snapshots__/Schema.test.ts.snap +++ b/src/models/__tests__/__snapshots__/Schema.test.ts.snap @@ -1,6 +1,6 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Models Schema schemaDefinition should resolve field with conditional operators 1`] = ` +exports[`Models > Schema > schemaDefinition should resolve field with conditional operators 1`] = ` { "absolutePointer": "/properties/test", "allOf": undefined, @@ -36,7 +36,7 @@ exports[`Models Schema schemaDefinition should resolve field with conditional op } `; -exports[`Models Schema schemaDefinition should resolve field with conditional operators 2`] = ` +exports[`Models > Schema > schemaDefinition should resolve field with conditional operators 2`] = ` { "absolutePointer": "/1", "allOf": undefined, @@ -74,7 +74,7 @@ exports[`Models Schema schemaDefinition should resolve field with conditional op } `; -exports[`Models Schema schemaDefinition should resolve schema with conditional operators 1`] = ` +exports[`Models > Schema > schemaDefinition should resolve schema with conditional operators 1`] = ` { "absolutePointer": "/2", "allOf": undefined, @@ -129,7 +129,7 @@ exports[`Models Schema schemaDefinition should resolve schema with conditional o } `; -exports[`Models Schema schemaDefinition should resolve schema with conditional operators 2`] = ` +exports[`Models > Schema > schemaDefinition should resolve schema with conditional operators 2`] = ` { "absolutePointer": "/1", "allOf": undefined, diff --git a/src/models/__tests__/fixtures/callback.json b/src/models/__tests__/fixtures/callback.json index 4e556669..0bf40bc7 100644 --- a/src/models/__tests__/fixtures/callback.json +++ b/src/models/__tests__/fixtures/callback.json @@ -4,6 +4,7 @@ "version": "1.0", "title": "Foo" }, + "paths": {}, "components": { "callbacks": { "Test": { diff --git a/src/models/__tests__/fixtures/discriminator-default-only.json b/src/models/__tests__/fixtures/discriminator-default-only.json new file mode 100644 index 00000000..b65ba8e9 --- /dev/null +++ b/src/models/__tests__/fixtures/discriminator-default-only.json @@ -0,0 +1,38 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "Discriminator with Only Default Mapping", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Animal": { + "type": "object", + "required": ["type"], + "discriminator": { + "propertyName": "type", + "defaultMapping": "#/components/schemas/GenericAnimal" + }, + "properties": { + "type": { + "type": "string" + } + } + }, + "GenericAnimal": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Animal" + } + ], + "properties": { + "species": { + "type": "string" + } + } + } + } + } +} diff --git a/src/models/__tests__/fixtures/discriminator-ordered-with-default.json b/src/models/__tests__/fixtures/discriminator-ordered-with-default.json new file mode 100644 index 00000000..43f3940c --- /dev/null +++ b/src/models/__tests__/fixtures/discriminator-ordered-with-default.json @@ -0,0 +1,62 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "Discriminator with Ordered Mapping and Default", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Vehicle": { + "type": "object", + "required": ["type"], + "discriminator": { + "propertyName": "type", + "mapping": { + "truck": "#/components/schemas/Truck", + "bike": "#/components/schemas/Bike", + "car": "#/components/schemas/Car" + }, + "defaultMapping": "#/components/schemas/GenericVehicle" + }, + "properties": { + "type": { + "type": "string" + } + } + }, + "Truck": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Vehicle" + } + ] + }, + "Bike": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Vehicle" + } + ] + }, + "Car": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Vehicle" + } + ] + }, + "GenericVehicle": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Vehicle" + } + ] + } + } + } +} diff --git a/src/models/__tests__/fixtures/discriminator-with-default-mapping.json b/src/models/__tests__/fixtures/discriminator-with-default-mapping.json new file mode 100644 index 00000000..4577ae6e --- /dev/null +++ b/src/models/__tests__/fixtures/discriminator-with-default-mapping.json @@ -0,0 +1,68 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "Discriminator with Default Mapping", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["type"], + "discriminator": { + "propertyName": "type", + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "defaultMapping": "#/components/schemas/GenericPet" + }, + "properties": { + "type": { + "type": "string" + } + } + }, + "Cat": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Pet" + } + ], + "properties": { + "meow": { + "type": "boolean" + } + } + }, + "Dog": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Pet" + } + ], + "properties": { + "bark": { + "type": "boolean" + } + } + }, + "GenericPet": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/Pet" + } + ], + "properties": { + "species": { + "type": "string" + } + } + } + } + } +} diff --git a/src/models/__tests__/fixtures/fields.json b/src/models/__tests__/fixtures/fields.json index 2f6d1f47..e31d35c8 100644 --- a/src/models/__tests__/fixtures/fields.json +++ b/src/models/__tests__/fixtures/fields.json @@ -4,6 +4,7 @@ "version": "1.0", "title": "Foo" }, + "paths": {}, "components": { "parameters": { "testParam": { diff --git a/src/models/__tests__/request.test.ts b/src/models/__tests__/request.test.ts index 5ab62c3c..989f23ac 100644 --- a/src/models/__tests__/request.test.ts +++ b/src/models/__tests__/request.test.ts @@ -1,11 +1,11 @@ import { parseYaml } from '@redocly/openapi-core'; import { outdent } from 'outdent'; -import type { OpenAPIDefinition } from '../../types'; -import type { OperationModel } from '../types'; +import type { OpenAPIDefinition } from '../../types/index.js'; +import type { OperationModel } from '../types.js'; -import { getRequestBody } from '../request'; -import { normalizeOptions, OpenAPIParser } from '../../services'; +import { getRequestBody } from '../request.js'; +import { normalizeOptions, OpenAPIParser } from '../../services/index.js'; const opts = normalizeOptions({}); describe('request', () => { @@ -23,14 +23,14 @@ describe('request', () => { }); test('should work with default props', () => { - const consoleError = jest.spyOn(global.console, 'error'); + const consoleError = vi.spyOn(global.console, 'error'); const req = getRequestBody(props); expect(consoleError).not.toHaveBeenCalled(); expect(req).toEqual({ description: '', required: undefined }); }); test('should work with set required', () => { - const consoleError = jest.spyOn(global.console, 'error'); + const consoleError = vi.spyOn(global.console, 'error'); props.infoOrRef.required = false; const req = getRequestBody(props); expect(consoleError).not.toHaveBeenCalled(); diff --git a/src/models/group.ts b/src/models/group.ts index 7b1b3cc9..40630c0f 100644 --- a/src/models/group.ts +++ b/src/models/group.ts @@ -14,7 +14,10 @@ export function getTagOrGroup( parent?: GroupModel, ): GroupModel { let id: string; - if (parent?.id && (type === 'schema' || type === 'mcp')) { + if ( + parent?.id && + (type === 'schema' || type === 'tool' || type === 'rsrc' || type === 'prompt') + ) { // handle schemas ids similar to how it is done for operations id = joinWithSeparator(parent.id, safeSlugify(tagOrGroup.name)); } else { diff --git a/src/models/mediaType.ts b/src/models/mediaType.ts index e3fa96dc..48e2a835 100644 --- a/src/models/mediaType.ts +++ b/src/models/mediaType.ts @@ -69,7 +69,8 @@ function generateExamples({ schema.discriminatorProp && typeof sample === 'object' && sample && - sample[schema.discriminatorProp] // handle case with readOnly with discriminator + sample[schema.discriminatorProp] && // handle case with readOnly with discriminator + !subSchema.isDefaultMapping ) { sample[schema.discriminatorProp] = subSchema.title; } @@ -148,6 +149,7 @@ export function getMediaType( ): MediaTypeModel { let examples; let formExamples; + let examplesPointer; const schema = info.schema && getSchema({ @@ -192,7 +194,9 @@ export function getMediaType( format: isXmlLike(name) ? 'xml' : 'json', }, }); + examplesPointer = schema?.pointer; } else if (isFormUrlEncoded(name) || isMultipartFormData(name)) { + examplesPointer = schema?.pointer; formExamples = generateExamples({ parser, info, @@ -209,6 +213,7 @@ export function getMediaType( return { examples, schema, + examplesPointer, name, isRequestType, formExamples, diff --git a/src/models/operation.ts b/src/models/operation.ts index f4d4e353..be5bd1c2 100644 --- a/src/models/operation.ts +++ b/src/models/operation.ts @@ -70,6 +70,7 @@ export function getOperation( operationId: operationDefinition.operationId, path: operationDefinition.pathName, isWebhook, + isAdditionalOperation: operationDefinition.isAdditionalOperation, isCallback: Boolean(callback?.isCallback), isEvent: callback?.isCallback || isWebhook, name: getOperationName(operationDefinition), diff --git a/src/models/schema.ts b/src/models/schema.ts index e4adac8d..529110dc 100644 --- a/src/models/schema.ts +++ b/src/models/schema.ts @@ -26,6 +26,7 @@ export function getSchema({ pointer, options, isChild = false, + isDefaultMapping = false, baseRefsStack = [], deps, absolutePointer, @@ -35,6 +36,7 @@ export function getSchema({ pointer: string; options: Options; isChild?: boolean; + isDefaultMapping?: boolean; baseRefsStack?: string[]; deps: Deps; absolutePointer?: string; @@ -54,6 +56,7 @@ export function getSchema({ operationPointer: deps.operation?.pointer || absolutePointer || '', schemaOrRef, isChild, + isDefaultMapping, typePrefix: '', pointer: schemaPointer, absolutePointer, @@ -289,13 +292,9 @@ function initDiscriminator({ const mapping = discriminator?.mapping || {}; - // Defines if the mapping is exhaustive. This avoids having references - // that overlap with the mapping entries - let isLimitedToMapping = discriminator?.['x-explicitMappingOnly'] || false; - // if there are no mappings, assume non-exhaustive - if (Object.keys(mapping).length === 0) { - isLimitedToMapping = false; - } + // When explicit mappings are defined, use only those mappings. + const isLimitedToMapping = + discriminator?.['x-explicitMappingOnly'] ?? Object.keys(mapping).length > 0; const explicitInvertedMapping = {}; for (const name in mapping) { @@ -313,7 +312,7 @@ function initDiscriminator({ ? { ...explicitInvertedMapping } : { ...implicitInvertedMapping, ...explicitInvertedMapping }; - let refs: Array<{ $ref; name }> = []; + let refs: Array<{ $ref; name; isDefaultMapping?: boolean }> = []; for (const $ref of Object.keys(invertedMapping)) { const names = invertedMapping[$ref]; @@ -326,6 +325,28 @@ function initDiscriminator({ } } + if (discriminator?.defaultMapping) { + const defaultRefIdx = refs.findIndex( + (ref) => ref.name === JsonPointer.baseName(discriminator.defaultMapping), + ); + const defaultMappingKey = 'Default mapping'; + + // if the default mapping added to refs from implicitInvertedMapping, update it to the default mapping key + if (~defaultRefIdx) { + refs[defaultRefIdx] = { + $ref: discriminator.defaultMapping, + name: defaultMappingKey, + isDefaultMapping: true, + }; + } else { + refs.push({ + $ref: discriminator.defaultMapping, + name: defaultMappingKey, + isDefaultMapping: true, + }); + } + } + // Make the listing respects the mapping // in case a mapping is defined, the user usually wants to have the order shown // as it was defined in the yaml. This will sort the names given the provided @@ -334,9 +355,17 @@ function initDiscriminator({ // - If a name is among the mapping, promote it to first // - Names among the mapping are sorted by their order in the mapping // - Names outside the mapping are sorted alphabetically + // - Default mapping is always last const names = Object.keys(mapping); if (names.length !== 0) { refs = refs.sort((left, right) => { + if (left.isDefaultMapping && !right.isDefaultMapping) { + return 1; + } + if (!left.isDefaultMapping && right.isDefaultMapping) { + return -1; + } + const indexLeft = names.indexOf(left.name); const indexRight = names.indexOf(right.name); @@ -355,13 +384,14 @@ function initDiscriminator({ }); } - const oneOf = refs.map(({ $ref, name }, index) => { + const oneOf = refs.map(({ $ref, name, isDefaultMapping }, index) => { const innerSchema = getSchema({ parser, schemaOrRef: { $ref }, pointer: $ref, options, isChild: true, + isDefaultMapping, baseRefsStack: refsStack.slice(0, -1), deps: { ...deps, @@ -374,6 +404,7 @@ function initDiscriminator({ innerSchema.title = name; return innerSchema; }); + return { oneOf, discriminatorProp, diff --git a/src/models/types.ts b/src/models/types.ts index d3d706bd..a30d1266 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -101,6 +101,7 @@ export type MediaTypeModel = { isRequestType: boolean; onlyRequiredInSamples: boolean; operation: OperationModel; + examplesPointer?: string; }; export type MediaContentModel = { @@ -186,6 +187,7 @@ export type OperationModel = { extensions: GenericObject; isCallback: boolean; isWebhook: boolean; + isAdditionalOperation: boolean; isEvent: boolean; callbackId: string; requestBody?: RequestBodyModel; @@ -217,6 +219,7 @@ export type GroupModel = { isSchema?: boolean; type: MenuItemGroupType; deprecated?: boolean; + isAdditionalOperation?: boolean; badges?: OpenAPIXBadges[]; items: ContentItemModel[]; @@ -242,6 +245,7 @@ export type OperationMenuItem = { httpVerb: string; path: string; isWebhook: boolean; + isAdditionalOperation: boolean; operationId?: string; badges?: OpenAPIXBadges[]; }; @@ -283,6 +287,7 @@ export type SchemaModel = { oneOf?: SchemaModel[]; oneOfType: string; discriminatorProp?: string; + isDefaultMapping?: boolean; rawSchema: OpenAPISchema; schema: MergedOpenAPISchema; diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts index ae115a55..47f560f6 100644 --- a/src/services/OpenAPIParser.ts +++ b/src/services/OpenAPIParser.ts @@ -135,13 +135,11 @@ export class OpenAPIParser { } mergeRefs(ref: OpenAPIRef, resolved: T, mergeAsAllOf: boolean): T { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { $ref, ...rest } = ref; const keys = Object.keys(rest); if (keys.length === 0) { - return { - ...resolved, - $ref, - }; + return resolved; } if ( mergeAsAllOf && @@ -250,6 +248,7 @@ export class OpenAPIParser { }; }) .filter(Boolean) as Array<{ + $ref?: string; schema: MergedOpenAPISchema; refsStack: string[]; absolutePointer: string; @@ -257,7 +256,7 @@ export class OpenAPIParser { for (const [ index, - { schema: subSchema, refsStack: subRefsStack, absolutePointer }, + { $ref: itemRef, schema: subSchema, refsStack: subRefsStack, absolutePointer }, ] of allOfSchemas.entries()) { const { type, @@ -359,7 +358,7 @@ export class OpenAPIParser { // TODO: do more intelligent merge receiver = { ...receiver, - title: receiver.title || title, + title: itemRef && title ? title : receiver.title || title, description: receiver.description || description, readOnly: !isUndefined(receiver.readOnly) ? receiver.readOnly : readOnly, writeOnly: !isUndefined(receiver.writeOnly) ? receiver.writeOnly : writeOnly, diff --git a/src/services/__tests__/OpenAPIParser.test.ts b/src/services/__tests__/OpenAPIParser.test.ts index eb53743f..02bd6f8e 100644 --- a/src/services/__tests__/OpenAPIParser.test.ts +++ b/src/services/__tests__/OpenAPIParser.test.ts @@ -1,5 +1,7 @@ -import { OpenAPIParser } from '../OpenAPIParser'; -import { normalizeOptions } from '../config-options'; +import type { OpenAPIDefinition, OpenAPISchema } from '../../types/index.js'; + +import { OpenAPIParser } from '../OpenAPIParser.js'; +import { normalizeOptions } from '../config-options/index.js'; const opts = normalizeOptions({}); @@ -8,39 +10,50 @@ describe('Models', () => { let parser; test('should hoist oneOfs when mergin allOf', async () => { - const spec = (await import('./fixtures/oneOfHoist.json')).default; + const spec = (await import('./fixtures/oneOfHoist.json')) + .default as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, opts); - expect(parser.mergeAllOf(spec.components.schemas.test)).toMatchSnapshot(); + expect(parser.mergeAllOf(spec.components?.schemas?.test)).toMatchSnapshot(); }); test('should not crash on self-referencing array in allOf', async () => { - const spec = (await import('./fixtures/allOfSelfReferencingArray.json')).default; + const spec = (await import('./fixtures/allOfSelfReferencingArray.json')) + .default as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, opts); - expect(parser.mergeAllOf(spec.components.schemas.Test)).toMatchSnapshot(); + expect(parser.mergeAllOf(spec.components?.schemas?.Test)).toMatchSnapshot(); }); test('should get schema name from named schema', async () => { - const spec = (await import('./fixtures/mergeAllOf.json')).default; + const spec = (await import('./fixtures/mergeAllOf.json')) + .default as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, opts); - const schema = parser.mergeAllOf(spec.components.schemas.Case1, '#/components/schemas/Case1'); + const schema = parser.mergeAllOf( + spec.components?.schemas?.Case1, + '#/components/schemas/Case1', + ); expect(schema.title).toEqual('Case1'); }); test('should get schema name from first allOf', async () => { - const spec = (await import('./fixtures/mergeAllOf.json')).default; + const spec = (await import('./fixtures/mergeAllOf.json')) + .default as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, opts); + const Case2 = spec.components?.schemas?.Case2 as OpenAPISchema & { + properties: { a: OpenAPISchema }; + }; const schema = parser.mergeAllOf( - spec.components.schemas.Case2.properties.a, + Case2.properties.a, '#components/schemas/Case2/properties/a', ); expect(schema.title).toEqual('Bar'); }); test('should get schema name from named schema in allOf', async () => { - const spec = (await import('./fixtures/mergeAllOf.json')).default; + const spec = (await import('./fixtures/mergeAllOf.json')) + .default as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, opts); const schema = parser.mergeAllOf( - spec.components.schemas.Case3.schemas.Foo, + (spec.components?.schemas?.Case3 as { schemas: { Foo: unknown } })?.schemas?.Foo, '#components/schemas/Case3/schemas/Foo', ); expect(schema.title).toEqual('Foo'); @@ -49,9 +62,10 @@ describe('Models', () => { test('should merge oneOf to inside allOff', async () => { // TODO: should hoist - const spec = (await import('./fixtures/mergeAllOf.json')).default; + const spec = (await import('./fixtures/mergeAllOf.json')) + .default as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, opts); - const schema = parser.mergeAllOf(spec.components.schemas.Case4); + const schema = parser.mergeAllOf(spec.components?.schemas?.Case4); expect(schema.title).toEqual('Foo'); expect(schema['x-parentRefs']).toHaveLength(1); expect(schema['x-parentRefs'][0]).toEqual('#/components/schemas/Ref'); diff --git a/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap b/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap index b086cdf4..cac41c74 100644 --- a/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap +++ b/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap @@ -1,6 +1,6 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Models Schema should hoist oneOfs when mergin allOf 1`] = ` +exports[`Models > Schema > should hoist oneOfs when mergin allOf 1`] = ` { "absolutePointer": "", "oneOf": [ @@ -150,7 +150,7 @@ exports[`Models Schema should hoist oneOfs when mergin allOf 1`] = ` } `; -exports[`Models Schema should not crash on self-referencing array in allOf 1`] = ` +exports[`Models > Schema > should not crash on self-referencing array in allOf 1`] = ` { "absolutePointer": "#/components/schemas/Array", "allOf": undefined, diff --git a/src/services/__tests__/check.test.ts b/src/services/__tests__/check.test.ts index f2ae651d..7edc1751 100644 --- a/src/services/__tests__/check.test.ts +++ b/src/services/__tests__/check.test.ts @@ -1,4 +1,4 @@ -import { isHostAllowed } from '../check'; +import { isHostAllowed } from '../check.js'; describe('license check', () => { test('isHostAllowed should work properly', () => { diff --git a/src/services/__tests__/telemetry.test.ts b/src/services/__tests__/telemetry.test.ts index 42bd5149..ade39d29 100644 --- a/src/services/__tests__/telemetry.test.ts +++ b/src/services/__tests__/telemetry.test.ts @@ -1,9 +1,9 @@ import type { EventType } from '@redocly/redoc-opentelemetry'; describe('RedocTelemetry', () => { - const mockFetch = jest.fn(); - const mockRandomUUID = jest.fn(); - const mockGetRandomValues = jest.fn(); + const mockFetch = vi.fn(); + const mockRandomUUID = vi.fn(); + const mockGetRandomValues = vi.fn(); beforeAll(() => { global.fetch = mockFetch; @@ -18,9 +18,9 @@ describe('RedocTelemetry', () => { }); beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); // A new instance is needed to clear session ID for each test - jest.resetModules(); + vi.resetModules(); }); it('should send an event with the correct structure', async () => { @@ -47,7 +47,7 @@ describe('RedocTelemetry', () => { expect(mockFetch).toHaveBeenCalledTimes(1); const fetchCall = mockFetch.mock.calls[0]; - expect(fetchCall[0]).toBe('https://otel.cloud.redocly.com/v1/traces'); + expect(fetchCall[0]).toBe('https://otel.blueharvest.cloud/v1/traces'); expect(fetchCall[1].method).toBe('POST'); expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); @@ -67,15 +67,24 @@ describe('RedocTelemetry', () => { }); expect(spanAttributes).toContainEqual({ key: 'cloudevents.event_data.booleanValue', - value: { booleanValue: 'true' }, + value: { boolValue: true }, }); expect(spanAttributes).toContainEqual({ key: 'cloudevents.event_data.objectValue', - value: { objValue: '{"key":"value"}' }, + value: { + kvlistValue: { + values: [ + { + key: 'key', + value: { stringValue: 'value' }, + }, + ], + }, + }, }); expect(spanAttributes).toContainEqual({ key: 'cloudevents.event_data.nullValue', - value: { objValue: 'null' }, + value: { stringValue: 'null' }, }); const resourceAttributes = body.resourceSpans[0].resource.attributes; @@ -85,6 +94,93 @@ describe('RedocTelemetry', () => { }); }); + it('should convert complex payloads to OTLP values', async () => { + mockRandomUUID.mockReturnValue('complex-session-uuid'); + mockGetRandomValues.mockImplementation((arr) => { + for (let i = 0; i < arr.length; i++) { + arr[i] = i; + } + return arr; + }); + + const { redocTelemetry: redocTelemetryInstance } = await import('../telemetry'); + + await redocTelemetryInstance.sendEvent( + 'complex_event' as EventType, + { + arrayValue: ['alpha', 5, true, null, { foo: 'bar' }], + nestedObject: { innerNumber: 1.5, innerArray: [1, 2], innerObject: { baz: false } }, + shouldIgnore: undefined, + } as any, + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const spanAttributes = body.resourceSpans[0].scopeSpans[0].spans[0].attributes; + + expect(spanAttributes).toContainEqual({ + key: 'cloudevents.event_data.arrayValue', + value: { + arrayValue: { + values: [ + { stringValue: 'alpha' }, + { intValue: 5 }, + { boolValue: true }, + { stringValue: 'null' }, + { + kvlistValue: { + values: [ + { + key: 'foo', + value: { stringValue: 'bar' }, + }, + ], + }, + }, + ], + }, + }, + }); + + expect(spanAttributes).toContainEqual({ + key: 'cloudevents.event_data.nestedObject', + value: { + kvlistValue: { + values: [ + { + key: 'innerNumber', + value: { doubleValue: 1.5 }, + }, + { + key: 'innerArray', + value: { + arrayValue: { + values: [{ intValue: 1 }, { intValue: 2 }], + }, + }, + }, + { + key: 'innerObject', + value: { + kvlistValue: { + values: [ + { + key: 'baz', + value: { boolValue: false }, + }, + ], + }, + }, + }, + ], + }, + }, + }); + + const attributeKeys = spanAttributes.map((attribute: { key: string }) => attribute.key); + expect(attributeKeys).not.toContain('cloudevents.event_data.shouldIgnore'); + }); + it('should generate and reuse session ID', async () => { mockRandomUUID.mockReturnValueOnce('first-uuid').mockReturnValueOnce('second-uuid'); diff --git a/src/services/__tests__/utils.test.ts b/src/services/__tests__/utils.test.ts index 899f2b86..2f3136cb 100644 --- a/src/services/__tests__/utils.test.ts +++ b/src/services/__tests__/utils.test.ts @@ -3,7 +3,7 @@ import { normalizeMimeType, unescapeServerVariables, arrayMergeStrategy, -} from '../utils'; +} from '../utils.js'; describe('services utils', () => { test('isSameMime should equal mime', () => { diff --git a/src/services/config-options/__tests__/helpers.test.ts b/src/services/config-options/__tests__/helpers.test.ts index 87c18842..c8415375 100644 --- a/src/services/config-options/__tests__/helpers.test.ts +++ b/src/services/config-options/__tests__/helpers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterAll, jest } from '@jest/globals'; +import { vi, type MockedFunction } from 'vitest'; import { normalizeShowExtensions, normalizeScrollYOffset, @@ -6,18 +6,18 @@ import { import { querySelector } from '../../../utils/dom.js'; import { isNumeric } from '../../../utils/helpers.js'; -jest.mock('../../../utils/dom.js'); -jest.mock('../../../utils/helpers.js'); +vi.mock('../../../utils/dom.js'); +vi.mock('../../../utils/helpers.js'); -const mockQuerySelector = querySelector as jest.MockedFunction; -const mockIsNumeric = isNumeric as jest.MockedFunction; +const mockQuerySelector = querySelector as MockedFunction; +const mockIsNumeric = isNumeric as unknown as MockedFunction; describe('helpers', () => { const originalConsoleWarn = console.warn; beforeEach(() => { - jest.clearAllMocks(); - console.warn = jest.fn(); + vi.clearAllMocks(); + console.warn = vi.fn(); }); afterAll(() => { @@ -85,8 +85,8 @@ describe('helpers', () => { it('should return function that returns element bottom for valid selector', () => { const mockElement = { - getBoundingClientRect: jest.fn().mockReturnValue({ bottom: 100 }), - } as any; + getBoundingClientRect: vi.fn().mockReturnValue({ bottom: 100 }), + } as unknown as Element; mockIsNumeric.mockReturnValue(false); mockQuerySelector.mockReturnValue(mockElement); @@ -129,7 +129,7 @@ describe('helpers', () => { }); it('should handle function that returns number', () => { - const mockFn = jest.fn<() => number>().mockReturnValue(120); + const mockFn = vi.fn<() => number>().mockReturnValue(120); const offsetFn = normalizeScrollYOffset(mockFn); const result = offsetFn(); @@ -139,9 +139,9 @@ describe('helpers', () => { }); it('should warn when function returns non-number', () => { - const mockFn = jest.fn<() => unknown>().mockReturnValue('invalid'); + const mockFn = vi.fn<() => unknown>().mockReturnValue('invalid'); - const offsetFn = normalizeScrollYOffset(mockFn as any); + const offsetFn = normalizeScrollYOffset(mockFn as () => number); const result = offsetFn(); expect(console.warn).toHaveBeenCalledWith( @@ -151,7 +151,7 @@ describe('helpers', () => { }); it('should return function that returns 0 for invalid value types', () => { - const offsetFn = normalizeScrollYOffset({} as any); + const offsetFn = normalizeScrollYOffset({} as unknown as number); const result = offsetFn(); expect(console.warn).toHaveBeenCalledWith( diff --git a/src/services/history/__tests__/helpers.test.ts b/src/services/history/__tests__/helpers.test.ts index 17ad4a02..6b204bf2 100644 --- a/src/services/history/__tests__/helpers.test.ts +++ b/src/services/history/__tests__/helpers.test.ts @@ -1,4 +1,4 @@ -import { joinWithSeparator } from '../helpers'; +import { joinWithSeparator } from '../helpers.js'; describe('History helpers', () => { describe('joinWithSeparator', () => { diff --git a/src/services/history/helpers.ts b/src/services/history/helpers.ts index 12bf20d3..29548d79 100644 --- a/src/services/history/helpers.ts +++ b/src/services/history/helpers.ts @@ -3,11 +3,6 @@ import type { FieldModel } from '../../models/index.js'; import { encodeBackSlashes, queryString } from '../../utils/index.js'; import { getActiveMediaType } from '../../models/mediaContent.js'; -export function isSameHash(a: string, b: string): boolean { - return a === b || '#' + a === b || a === '#' + b; -} - -// TODO: Add tests function getContentTypeName(field: FieldModel, activeMimeName?: string) { // param in "query" | "header" | "path" | "cookie" - no need for content type in link if (field.in) { diff --git a/src/services/menu/__tests__/builder.test.ts b/src/services/menu/__tests__/builder.test.ts index bb809a3a..a49b22b0 100644 --- a/src/services/menu/__tests__/builder.test.ts +++ b/src/services/menu/__tests__/builder.test.ts @@ -1,7 +1,10 @@ -import { normalizeOptions } from '../../config-options'; -import { OpenAPIParser } from '../../OpenAPIParser'; -import { buildMenuStructure } from '../builder'; -import { addMarkdownItems } from '../markdown'; +import type { OperationMenuItem } from '../../../models/index.js'; +import type { OpenAPIDefinition } from '../../../types/index.js'; + +import { normalizeOptions } from '../../config-options/index.js'; +import { OpenAPIParser } from '../../OpenAPIParser.js'; +import { buildMenuStructure } from '../builder.js'; +import { addMarkdownItems } from '../markdown.js'; const opts = normalizeOptions({}); @@ -10,7 +13,8 @@ describe('Menu', () => { let parser; test('should resolve pathItems', async () => { - const spec = (await import('./fixtures/pathItems.json')).default; + const spec = (await import('./fixtures/pathItems.json')) + .default as unknown as OpenAPIDefinition; parser = new OpenAPIParser(spec, undefined, opts); const contentItems = buildMenuStructure(parser, opts); expect(contentItems).toHaveLength(3); @@ -23,6 +27,116 @@ describe('Menu', () => { expect(contentItems[2].name).toEqual('pet'); expect(contentItems[2].type).toEqual('tag'); }); + + test('should include operations from additionalOperations', () => { + const spec = { + openapi: '3.2.0', + info: { title: 'Test API', version: '1.0.0' }, + tags: [{ name: 'operations' }], + paths: { + '/test': { + get: { + summary: 'Get test', + tags: ['operations'], + responses: { '200': { description: 'Success' } }, + }, + additionalOperations: { + test: { + summary: 'Custom TEST method', + tags: ['operations'], + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }, + }; + + parser = new OpenAPIParser(spec, undefined, opts); + const contentItems = buildMenuStructure(parser, opts); + + const operationsTag = contentItems.find((item) => item.id === 'operations'); + expect(operationsTag).toBeDefined(); + expect(operationsTag?.items).toHaveLength(2); + + const getOperation = operationsTag?.items.find( + (item) => item.type === 'operation' && (item as OperationMenuItem).httpVerb === 'get', + ) as OperationMenuItem; + expect(getOperation).toBeDefined(); + expect(getOperation.type).toBe('operation'); + + const testOperation = operationsTag?.items.find( + (item) => item.type === 'operation' && (item as OperationMenuItem).httpVerb === 'test', + ) as OperationMenuItem; + expect(testOperation).toBeDefined(); + expect(testOperation.type).toBe('operation'); + expect(testOperation.name).toContain('TEST'); + }); + + test('should handle paths with only additionalOperations', () => { + const spec = { + openapi: '3.2.0', + info: { title: 'Test API', version: '1.0.0' }, + tags: [{ name: 'custom' }], + paths: { + '/custom-only': { + additionalOperations: { + greet: { + summary: 'Custom GREET method', + tags: ['custom'], + responses: { '200': { description: 'Success' } }, + }, + wave: { + summary: 'Custom WAVE method', + tags: ['custom'], + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }, + }; + + parser = new OpenAPIParser(spec, undefined, opts); + const contentItems = buildMenuStructure(parser, opts); + + const customTag = contentItems.find((item) => item.id === 'custom'); + expect(customTag).toBeDefined(); + expect(customTag?.items).toHaveLength(2); + + const greetOperation = customTag?.items.find( + (item) => item.type === 'operation' && (item as OperationMenuItem).httpVerb === 'greet', + ); + expect(greetOperation).toBeDefined(); + + const waveOperation = customTag?.items.find( + (item) => item.type === 'operation' && (item as OperationMenuItem).httpVerb === 'wave', + ); + expect(waveOperation).toBeDefined(); + }); + + test('should not fail when additionalOperations is empty', () => { + const spec = { + openapi: '3.2.0', + info: { title: 'Test API', version: '1.0.0' }, + tags: [{ name: 'operations' }], + paths: { + '/test': { + get: { + summary: 'Get test', + tags: ['operations'], + responses: { '200': { description: 'Success' } }, + }, + additionalOperations: {}, + }, + }, + }; + + parser = new OpenAPIParser(spec, undefined, opts); + const contentItems = buildMenuStructure(parser, opts); + + const operationsTag = contentItems.find((item) => item.id === 'operations'); + expect(operationsTag).toBeDefined(); + expect(operationsTag?.items).toHaveLength(1); + }); }); describe('addMarkdownItems', () => { diff --git a/src/services/menu/__tests__/fixtures/pathItems.json b/src/services/menu/__tests__/fixtures/pathItems.json index ffcdbe5b..09873fa1 100644 --- a/src/services/menu/__tests__/fixtures/pathItems.json +++ b/src/services/menu/__tests__/fixtures/pathItems.json @@ -5,6 +5,7 @@ "title": "Swagger Petstore" }, "tags": [{ "name": "pet2" }, { "name": "pet" }], + "paths": {}, "webhooks": { "myWebhook": { "$ref": "#/components/pathItems/catsWebhook", diff --git a/src/services/menu/__tests__/operation.test.ts b/src/services/menu/__tests__/operation.test.ts new file mode 100644 index 00000000..0f06c489 --- /dev/null +++ b/src/services/menu/__tests__/operation.test.ts @@ -0,0 +1,112 @@ +import { getOperationsItems } from '../operation.js'; + +vi.mock('../../../utils/index.js', () => ({ + encodeBackSlashes: vi.fn((str) => str.toLowerCase()), + getOperationId: vi.fn( + (operation, parent) => `${parent?.id || 'root'}-${operation.httpVerb}-${operation.pathName}`, + ), + getOperationName: vi.fn( + (operation) => operation.summary || `${operation.httpVerb.toUpperCase()} ${operation.pathName}`, + ), +})); + +describe('getOperationsItems sorting logic', () => { + const mockParent = { + id: 'test-tag', + name: 'Test Tag', + type: 'tag' as const, + items: [], + depth: 1, + level: 1, + href: '/test-tag', + }; + + const createMockOperation = (overrides = {}) => ({ + httpVerb: 'get', + pathName: '/test', + summary: 'Test operation', + deprecated: false, + isAdditionalOperation: false, + isWebhook: false, + operationId: 'test-operation', + 'x-badges': [], + ...overrides, + }); + + const createMockTag = (operations) => ({ + name: 'test-tag', + operations, + }); + + test('should sort non-deprecated operations before deprecated ones', () => { + const operations = [ + createMockOperation({ httpVerb: 'post', summary: 'Deprecated POST', deprecated: true }), + createMockOperation({ httpVerb: 'get', summary: 'Active GET', deprecated: false }), + createMockOperation({ httpVerb: 'put', summary: 'Deprecated PUT', deprecated: true }), + createMockOperation({ httpVerb: 'delete', summary: 'Active DELETE', deprecated: false }), + ]; + + const tag = createMockTag(operations); + const result = getOperationsItems(mockParent, tag, 1); + + expect(result).toHaveLength(4); + expect(result[0].httpVerb).toBe('get'); + expect(result[0].deprecated).toBe(false); + expect(result[1].httpVerb).toBe('delete'); + expect(result[1].deprecated).toBe(false); + expect(result[2].httpVerb).toBe('post'); + expect(result[2].deprecated).toBe(true); + expect(result[3].httpVerb).toBe('put'); + expect(result[3].deprecated).toBe(true); + }); + + test('should apply combined sorting: non-deprecated regular, non-deprecated additional, deprecated regular, deprecated additional', () => { + const operations = [ + createMockOperation({ + httpVerb: 'deprecated-additional', + summary: 'Deprecated additional operation', + isAdditionalOperation: true, + deprecated: true, + }), + createMockOperation({ + httpVerb: 'active-regular', + summary: 'Active regular operation', + isAdditionalOperation: false, + deprecated: false, + }), + createMockOperation({ + httpVerb: 'deprecated-regular', + summary: 'Deprecated regular operation', + isAdditionalOperation: false, + deprecated: true, + }), + createMockOperation({ + httpVerb: 'active-additional', + summary: 'Active additional operation', + isAdditionalOperation: true, + deprecated: false, + }), + ]; + + const tag = createMockTag(operations); + const result = getOperationsItems(mockParent, tag, 1); + + expect(result).toHaveLength(4); + + expect(result[0].httpVerb).toBe('active-regular'); + expect(result[0].deprecated).toBe(false); + expect(result[0].isAdditionalOperation).toBe(false); + + expect(result[1].httpVerb).toBe('active-additional'); + expect(result[1].deprecated).toBe(false); + expect(result[1].isAdditionalOperation).toBe(true); + + expect(result[2].httpVerb).toBe('deprecated-regular'); + expect(result[2].deprecated).toBe(true); + expect(result[2].isAdditionalOperation).toBe(false); + + expect(result[3].httpVerb).toBe('deprecated-additional'); + expect(result[3].deprecated).toBe(true); + expect(result[3].isAdditionalOperation).toBe(true); + }); +}); diff --git a/src/services/menu/__tests__/tags.test.ts b/src/services/menu/__tests__/tags.test.ts new file mode 100644 index 00000000..e53bcbb8 --- /dev/null +++ b/src/services/menu/__tests__/tags.test.ts @@ -0,0 +1,85 @@ +import { processOperation } from '../tags.js'; +import type { OpenAPIParser } from '../../OpenAPIParser.js'; + +describe('processOperation', () => { + let parser: OpenAPIParser; + let tagsMap: any; + + beforeEach(() => { + parser = { + definition: {}, + deref: vi.fn().mockReturnValue({ resolved: {} }), + } as unknown as OpenAPIParser; + + tagsMap = {}; + }); + + it('adds operation to existing tag', () => { + const operationInfo = { summary: 'Test op', tags: ['tag1'] }; + tagsMap['tag1'] = { name: 'tag1', operations: [] }; + + processOperation(parser, 'get', operationInfo, '/path', {}, tagsMap); + + expect(tagsMap['tag1'].operations).toHaveLength(1); + expect(tagsMap['tag1'].operations[0].summary).toBe('Test op'); + }); + + it('creates tag if it does not exist', () => { + const operationInfo = { summary: 'New op', tags: ['newTag'] }; + + processOperation(parser, 'post', operationInfo, '/path', {}, tagsMap); + + expect(tagsMap['newTag']).toBeDefined(); + expect(tagsMap['newTag'].operations[0].summary).toBe('New op'); + }); + + it('assigns empty tag if operation has no tags', () => { + const operationInfo = { summary: 'No tag op' }; + + processOperation(parser, 'put', operationInfo, '/path', {}, tagsMap); + + expect(tagsMap['']).toBeDefined(); + expect(tagsMap[''].operations[0].httpVerb).toBe('put'); + }); + + it('assigns default webhook tag if isWebhook is true and no tags', () => { + const operationInfo = { summary: 'Webhook op' }; + + processOperation(parser, 'post', operationInfo, '/webhook', {}, tagsMap, true); + + expect(tagsMap['webhooks']).toBeDefined(); + expect(tagsMap['webhooks'].operations[0].isWebhook).toBe(true); + }); + + it('skips operations with x-traitTag', () => { + const operationInfo = { summary: 'Trait op', tags: ['traitTag'] }; + tagsMap['traitTag'] = { name: 'traitTag', operations: [], 'x-traitTag': true }; + + processOperation(parser, 'get', operationInfo, '/path', {}, tagsMap); + + expect(tagsMap['traitTag'].operations).toHaveLength(0); + }); + + it('processes additionalOperations correctly', () => { + const path = { + additionalOperations: { + test: { summary: 'Additional op', tags: ['tag2'] }, + }, + }; + tagsMap['tag2'] = { name: 'tag2', operations: [] }; + + processOperation( + parser, + 'test', + path.additionalOperations.test, + '/path', + path, + tagsMap, + false, + true, + ); + + expect(tagsMap['tag2'].operations).toHaveLength(1); + expect(tagsMap['tag2'].operations[0].isAdditionalOperation).toBe(true); + }); +}); diff --git a/src/services/menu/operation.ts b/src/services/menu/operation.ts index 86bfca72..4a4fac96 100644 --- a/src/services/menu/operation.ts +++ b/src/services/menu/operation.ts @@ -34,6 +34,7 @@ export function getOperationsItems( httpVerb: operationDefinition.httpVerb, path: operationDefinition.pathName, items: [], + isAdditionalOperation: operationDefinition.isAdditionalOperation, isWebhook: operationDefinition.isWebhook, operationId: operationDefinition.operationId, badges: operationDefinition['x-badges'] || [], @@ -49,7 +50,11 @@ export function getOperationsItems( } return [ - ...operations.sort((a, b) => Number(a.deprecated) - Number(b.deprecated)), + ...operations.sort( + (a, b) => + Number(a.deprecated) - Number(b.deprecated) || + Number(a.isAdditionalOperation) - Number(b.isAdditionalOperation), + ), ...webhooks.sort((a, b) => Number(a.deprecated) - Number(b.deprecated)), // webhooks must be in the end for adding separator ]; } diff --git a/src/services/menu/tags.ts b/src/services/menu/tags.ts index 393a9994..479d3c36 100644 --- a/src/services/menu/tags.ts +++ b/src/services/menu/tags.ts @@ -22,7 +22,7 @@ import { getOperationsItems } from './operation.js'; import { addMarkdownItems } from './markdown.js'; import { joinWithSeparator } from '../history/helpers.js'; -const SUPPORTED_MCP_TYPES = ['tools']; // TODO: implement resources and prompts +const SUPPORTED_MCP_TYPES = ['tools', 'resources', 'prompts']; // TODO: implement resources and prompts /** * Returns array of OperationsGroup items for the tags of the group or for all tags @@ -188,12 +188,14 @@ function getTagRelatedMcp( const toolTags = tool.tags?.length ? tool.tags : defaultTags; if (toolTags.includes(tag.name)) { const id = joinWithSeparator(parent.id, safeSlugify(tool.name)); + const prefix = type === 'tools' ? `Tool name: \`${tool.name}\`\n\n` : ''; + const customType = type === 'tools' ? 'tool' : type === 'resources' ? 'rsrc' : 'prompt'; const item = getTagOrGroup( - 'mcp', + customType, { name: tool.name, 'x-displayName': tool.title || tool.name, - description: `${tool.description || ''}\n{% mcp${typeName.slice(0, -1)} toolName="${tool.name}" id="${id}" /%}`, + description: `${prefix}${tool.description || ''}\n{% mcp${typeName.slice(0, -1)} name="${tool.name}" id="${id}" /%}`, isSchema: true, level: 2, } as OpenAPITag, @@ -256,6 +258,54 @@ function getMcpTags(mcp: OpenAPIMcp, tags: TagsInfoMap) { } } +export function processOperation( + parser: OpenAPIParser, + operationName: string, + operationInfo: any, + pathName: string, + path: any, + tags: TagsInfoMap, + isWebhook?: boolean, + isAdditionalOperation?: boolean, +) { + if (path.$ref) { + const { resolved: resolvedPaths } = parser.deref(path as OpenAPIPaths); + getTags(parser, { [pathName]: resolvedPaths }, tags, isWebhook); + return; + } + + let operationTags = operationInfo?.tags; + + if (!operationTags || !operationTags.length) { + // empty tag for operations and default tag for webhooks + operationTags = isWebhook ? [DEFAULT_WEBHOOKS_TAG_NAME] : ['']; + } + + for (const tagName of operationTags) { + let tag = tags[tagName]; + if (tag === undefined) { + tag = { + name: tagName, + operations: [], + }; + tags[tagName] = tag; + } + if (tag['x-traitTag']) { + continue; + } + tag.operations.push({ + ...operationInfo, + pathName, + pointer: JsonPointer.compile(['paths', pathName, operationName]), + httpVerb: operationName, + pathParameters: path.parameters || [], + pathServers: path.servers, + isWebhook: !!isWebhook, + isAdditionalOperation: !!isAdditionalOperation, + }); + } +} + function getTags( parser: OpenAPIParser, paths: OpenAPIPaths, @@ -265,41 +315,24 @@ function getTags( for (const pathName of Object.keys(paths || {})) { const path = paths[pathName]; const operations = Object.keys(path).filter(isOperationName); + for (const operationName of operations) { const operationInfo = path[operationName]; - if (path.$ref) { - const { resolved: resolvedPaths } = parser.deref(path as OpenAPIPaths); - getTags(parser, { [pathName]: resolvedPaths }, tags, isWebhook); - continue; - } - let operationTags = operationInfo?.tags; + processOperation(parser, operationName, operationInfo, pathName, path, tags, isWebhook); + } - if (!operationTags || !operationTags.length) { - // empty tag for operations and default tag for webhooks - operationTags = isWebhook ? [DEFAULT_WEBHOOKS_TAG_NAME] : ['']; - } - - for (const tagName of operationTags) { - let tag = tags[tagName]; - if (tag === undefined) { - tag = { - name: tagName, - operations: [], - }; - tags[tagName] = tag; - } - if (tag['x-traitTag']) { - continue; - } - tag.operations.push({ - ...operationInfo, + if (path.additionalOperations) { + for (const [operationName, operationInfo] of Object.entries(path.additionalOperations)) { + processOperation( + parser, + operationName, + operationInfo, pathName, - pointer: JsonPointer.compile(['paths', pathName, operationName]), - httpVerb: operationName, - pathParameters: path.parameters || [], - pathServers: path.servers, - isWebhook: !!isWebhook, - }); + path, + tags, + isWebhook, + true, + ); } } } diff --git a/src/services/search/init.ts b/src/services/search/init.ts index e182bc1b..9cc5bc63 100644 --- a/src/services/search/init.ts +++ b/src/services/search/init.ts @@ -153,6 +153,7 @@ const addOperation = (operation: OperationModel): SearchDocument => { text: stripFormatting(removeMarkdownLinks(operation.description || '')), httpMethod: operation.httpVerb, httpPath: operation.path, + isAdditionalOperation: operation.isAdditionalOperation, deprecated: operation.deprecated, security: operation.security .map((s) => s.schemes.map((scheme) => scheme.id)) diff --git a/src/services/search/types.ts b/src/services/search/types.ts index d169938a..f8c9f0f0 100644 --- a/src/services/search/types.ts +++ b/src/services/search/types.ts @@ -9,6 +9,7 @@ export type SearchDocument = { path?: string[]; httpMethod?: string; httpPath?: string | string[]; + isAdditionalOperation?: boolean; deprecated?: boolean; security?: string[]; parameters?: OperationParameter[]; diff --git a/src/services/telemetry.ts b/src/services/telemetry.ts index e99f980e..dda46049 100644 --- a/src/services/telemetry.ts +++ b/src/services/telemetry.ts @@ -1,7 +1,18 @@ -const OTEL_TRACES_URL = 'https://otel.cloud.redocly.com/v1/traces'; // Prod: 'https://otel.cloud.redocly.com/v1/traces'; +const OTEL_TRACES_URL = 'https://otel.blueharvest.cloud/v1/traces'; // Prod: 'https://otel.cloud.redocly.com/v1/traces'; import type { EventType, EventPayload } from '@redocly/redoc-opentelemetry'; +type OtlpPrimitive = + | { stringValue: string } + | { boolValue: boolean } + | { intValue: number } + | { doubleValue: number }; + +type OtlpValue = + | OtlpPrimitive + | { arrayValue: { values: OtlpValue[] } } + | { kvlistValue: { values: { key: string; value: OtlpValue }[] } }; + class RedocTelemetry { private sessionId: string = ''; @@ -62,37 +73,83 @@ class RedocTelemetry { } private dataToCloudEventData(data: EventPayload) { - const cloudEventData = Object.entries(data || {}).map(([key, value]) => { - switch (typeof value) { - case 'number': - return { - key: `cloudevents.event_data.${key}`, - value: { intValue: value }, - }; - case 'object': - return { - key: `cloudevents.event_data.${key}`, - value: { objValue: JSON.stringify(value) }, - }; - case 'string': - return { - key: `cloudevents.event_data.${key}`, - value: { stringValue: value.toString() }, - }; - case 'boolean': - return { - key: `cloudevents.event_data.${key}`, - value: { booleanValue: value.toString() }, - }; - default: - return { - key: `cloudevents.event_data.${key}`, - value: { stringValue: value ? value.toString() : 'unknown_value' }, - }; - } - }); + if (!data) { + return []; + } - return cloudEventData; + return Object.entries(data) + .map(([key, value]) => { + const otlpValue = this.toOtlpValue(value); + + if (!otlpValue) { + return null; + } + + return { + key: `cloudevents.event_data.${key}`, + value: otlpValue, + }; + }) + .filter((attribute): attribute is { key: string; value: OtlpValue } => attribute !== null); + } + + private toOtlpValue(value: unknown): OtlpValue | null { + if (value === undefined) { + return null; + } + + if (value === null) { + return { stringValue: 'null' }; + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return { stringValue: value.toString() }; + } + + return Number.isInteger(value) ? { intValue: value } : { doubleValue: value }; + } + + if (typeof value === 'boolean') { + return { boolValue: value }; + } + + if (typeof value === 'string') { + return { stringValue: value }; + } + + if (Array.isArray(value)) { + const values = value + .map((item) => this.toOtlpValue(item)) + .filter((item): item is OtlpValue => item !== null); + + return { arrayValue: { values } }; + } + + if (typeof value === 'object') { + if (Object.prototype.toString.call(value) !== '[object Object]') { + return { stringValue: String(value) }; + } + + const entries = Object.entries(value as Record) + .map(([key, nestedValue]) => { + const otlpValue = this.toOtlpValue(nestedValue); + + if (!otlpValue) { + return null; + } + + return { + key, + value: otlpValue, + }; + }) + .filter((entry): entry is { key: string; value: OtlpValue } => entry !== null); + + return { kvlistValue: { values: entries } }; + } + + return { stringValue: String(value) }; } } diff --git a/src/services/types.ts b/src/services/types.ts index 4852d6c8..b55d5a63 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -27,7 +27,7 @@ export type ExternalLinkSeparator = { separatorLine?: boolean; }; -export type MenuItemGroupType = 'group' | 'tag' | 'section' | 'schema' | 'mcp'; +export type MenuItemGroupType = 'group' | 'tag' | 'section' | 'schema' | 'tool' | 'rsrc' | 'prompt'; export type MenuItemType = MenuItemGroupType | 'operation'; /** Generic interface for MenuItems */ @@ -40,6 +40,7 @@ export interface IMenuItem { items: IMenuItem[]; parent?: IMenuItem; deprecated?: boolean; + isAdditionalOperation?: boolean; type: MenuItemType; isSchema?: boolean; httpVerb?: string; @@ -62,6 +63,7 @@ export type ExtendedOpenAPIOperation = { pathParameters: Array>; pathServers: Array | undefined; isWebhook: boolean; + isAdditionalOperation: boolean; defaultSampleName?: string | false; } & OpenAPIOperation; diff --git a/src/setupTests.ts b/src/setupTests.ts deleted file mode 100644 index ddaec007..00000000 --- a/src/setupTests.ts +++ /dev/null @@ -1,16 +0,0 @@ -import '@testing-library/jest-dom'; -import 'jest-styled-components'; -import { randomUUID } from 'node:crypto'; - -window.crypto.randomUUID = randomUUID; -global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); -// Mock fetch globally for all tests -global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({}), - text: () => Promise.resolve(''), - headers: new Headers(), - } as Response), -); diff --git a/src/testProviders.tsx b/src/testProviders.tsx index b098a592..52057718 100644 --- a/src/testProviders.tsx +++ b/src/testProviders.tsx @@ -1,11 +1,34 @@ import { Children } from 'react'; +import { BrowserRouter, MemoryRouter } from 'react-router-dom'; import type { ReactNode, ReactElement } from 'react'; import type { OpenAPIDefinition } from './types'; import type { StoreProviderProps } from './components'; +import type { BrowserRouterProps, MemoryRouterProps } from 'react-router-dom'; import { StoreProvider } from './components'; +const routerFutureFlags = { + v7_startTransition: true, + v7_relativeSplatPath: true, +}; + +export function TestBrowserRouter({ children, ...props }: BrowserRouterProps): ReactElement { + return ( + + {children} + + ); +} + +export function TestMemoryRouter({ children, ...props }: MemoryRouterProps): ReactElement { + return ( + + {children} + + ); +} + export function withTestProviders(children: ReactNode, store?: StoreProviderProps): ReactElement { return ( diff --git a/src/types/app.ts b/src/types/app.ts index 7a57ac1f..f820d903 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,6 +1 @@ export type RouterType = 'history' | 'memory' | 'hash'; -export type MenuLink = { - id: string; - href: string; - name: string; -}; diff --git a/src/types/open-api.ts b/src/types/open-api.ts index b2f377d4..475f9019 100644 --- a/src/types/open-api.ts +++ b/src/types/open-api.ts @@ -89,6 +89,7 @@ export interface OpenAPIPath extends Partial, ParsedDescriptionWithS head?: OpenAPIOperation; patch?: OpenAPIOperation; trace?: OpenAPIOperation; + additionalOperations?: Record; servers?: OpenAPIServer[]; parameters?: Array>; } @@ -208,6 +209,7 @@ export interface OpenAPIDiscriminator { propertyName: string; mapping?: { [name: string]: string }; 'x-explicitMappingOnly'?: boolean; + defaultMapping?: string; } export interface OpenAPIMediaType { @@ -403,6 +405,8 @@ export interface McpResource { description?: string; uri: string; mimeType: string; + blob?: string; + text?: string; security?: OpenAPISecurityRequirement[]; tags?: string[]; @@ -414,10 +418,14 @@ export interface McpPrompt { title?: string; description: string; arguments: McpPromptArgument[]; + security?: OpenAPISecurityRequirement[]; + tags?: string[]; + 'x-badges'?: OpenAPIXBadges[]; } export interface McpPromptArgument { name: string; description: string; required: boolean; + example?: string; } diff --git a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap index b6d2a67a..29ea36af 100644 --- a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap +++ b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap @@ -1,6 +1,6 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`#loadAndBundleDefinition should load And Bundle Spec openapi-3-1.yaml 1`] = ` +exports[`#loadAndBundleDefinition > should load And Bundle Spec openapi-3-1.yaml 1`] = ` { "components": { "examples": { @@ -1959,7 +1959,7 @@ culpa qui officia deserunt mollit anim id est laborum. } `; -exports[`#loadAndBundleDefinition should load and bundle spec petstore.yaml 1`] = ` +exports[`#loadAndBundleDefinition > should load and bundle spec petstore.yaml 1`] = ` { "components": { "examples": { @@ -4013,7 +4013,7 @@ Some Item } `; -exports[`#loadAndBundleDefinition should load and bundle spec rebilly.yaml 1`] = ` +exports[`#loadAndBundleDefinition > should load and bundle spec rebilly.yaml 1`] = ` { "components": { "examples": { diff --git a/src/utils/__tests__/__snapshots__/simplifyAstStructure.test.ts.snap b/src/utils/__tests__/__snapshots__/simplifyAstStructure.test.ts.snap index 47e58c60..e41fa425 100644 --- a/src/utils/__tests__/__snapshots__/simplifyAstStructure.test.ts.snap +++ b/src/utils/__tests__/__snapshots__/simplifyAstStructure.test.ts.snap @@ -1,6 +1,6 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`simplifyAstStructure should handle empty AST 1`] = ` +exports[`simplifyAstStructure > should handle empty AST 1`] = ` Node { "$$mdtype": "Node", "annotations": [], @@ -14,7 +14,7 @@ Node { } `; -exports[`simplifyAstStructure should remove location from AST nodes 1`] = ` +exports[`simplifyAstStructure > should remove location from AST nodes 1`] = ` [ Node { "$$mdtype": "Node", @@ -44,7 +44,7 @@ exports[`simplifyAstStructure should remove location from AST nodes 1`] = ` ] `; -exports[`simplifyAstStructure should return full AST when not a document node type 1`] = ` +exports[`simplifyAstStructure > should return full AST when not a document node type 1`] = ` Node { "$$mdtype": "Node", "annotations": [], diff --git a/src/utils/__tests__/areArraysEqual.test.ts b/src/utils/__tests__/areArraysEqual.test.ts index dbe20ea8..1b8e8dec 100644 --- a/src/utils/__tests__/areArraysEqual.test.ts +++ b/src/utils/__tests__/areArraysEqual.test.ts @@ -1,4 +1,4 @@ -import { areArraysEqual } from '../areArraysEqual'; +import { areArraysEqual } from '../areArraysEqual.js'; describe('areArraysEqual', () => { it('should return true for two empty arrays', () => { diff --git a/src/utils/__tests__/convertSwagger2OpenAPI.test.ts b/src/utils/__tests__/convertSwagger2OpenAPI.test.ts index cf19150a..38a95e83 100644 --- a/src/utils/__tests__/convertSwagger2OpenAPI.test.ts +++ b/src/utils/__tests__/convertSwagger2OpenAPI.test.ts @@ -1,18 +1,18 @@ -import { jest } from '@jest/globals'; +import { vi, type Mocked } from 'vitest'; import swagger2openapi from 'swagger2openapi'; import type { OpenAPIDefinition } from '../../types/index.js'; -import { convertSwagger2OpenAPI } from '../convertSwagger2OpenAPI'; +import { convertSwagger2OpenAPI } from '../convertSwagger2OpenAPI.js'; // Mock the swagger2openapi module -jest.mock('swagger2openapi'); +vi.mock('swagger2openapi'); -const mockSwagger2OpenAPI = swagger2openapi as jest.Mocked; +const mockSwagger2OpenAPI = swagger2openapi as Mocked; describe('convertSwagger2OpenAPI', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('should convert a valid Swagger 2.0 spec to OpenAPI 3.0', async () => { @@ -54,9 +54,9 @@ describe('convertSwagger2OpenAPI', () => { }, }; - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(null, { openapi: expectedOpenAPISpec }); - return undefined as any; + return undefined as never; }); const result = await convertSwagger2OpenAPI(swaggerSpec); @@ -87,9 +87,9 @@ describe('convertSwagger2OpenAPI', () => { paths: {}, }; - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(null, { openapi: expectedOpenAPISpec }); - return undefined as any; + return undefined as never; }); await convertSwagger2OpenAPI(swaggerSpec); @@ -140,9 +140,9 @@ describe('convertSwagger2OpenAPI', () => { }, }; - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(null, { openapi: expectedOpenAPISpec }); - return undefined as any; + return undefined as never; }); await convertSwagger2OpenAPI(swaggerSpec); @@ -166,9 +166,9 @@ describe('convertSwagger2OpenAPI', () => { const conversionError = new Error('Invalid Swagger specification'); - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(conversionError, null); - return undefined as any; + return undefined as never; }); await expect(convertSwagger2OpenAPI(swaggerSpec)).rejects.toThrow( @@ -192,9 +192,9 @@ describe('convertSwagger2OpenAPI', () => { paths: {}, }; - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(null, null); - return undefined as any; + return undefined as never; }); const result = await convertSwagger2OpenAPI(swaggerSpec); @@ -212,9 +212,9 @@ describe('convertSwagger2OpenAPI', () => { paths: {}, }; - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(null, { someOtherProperty: 'value' }); - return undefined as any; + return undefined as never; }); const result = await convertSwagger2OpenAPI(swaggerSpec); @@ -391,9 +391,9 @@ describe('convertSwagger2OpenAPI', () => { }, }; - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(null, { openapi: expectedOpenAPISpec }); - return undefined as any; + return undefined as never; }); const result = await convertSwagger2OpenAPI(swaggerSpec); @@ -418,9 +418,9 @@ describe('convertSwagger2OpenAPI', () => { paths: {}, }; - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(null, { openapi: expectedOpenAPISpec }); - return undefined as any; + return undefined as never; }); const result = await convertSwagger2OpenAPI(swaggerSpec); @@ -452,9 +452,9 @@ describe('convertSwagger2OpenAPI', () => { paths: {}, }; - mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => { + mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => { callback(null, { openapi: expectedOpenAPISpec }); - return undefined as any; + return undefined as never; }); const result = await convertSwagger2OpenAPI(swaggerSpec); diff --git a/src/utils/__tests__/debounce.test.ts b/src/utils/__tests__/debounce.test.ts index 8d31a838..83d1fc9e 100644 --- a/src/utils/__tests__/debounce.test.ts +++ b/src/utils/__tests__/debounce.test.ts @@ -1,10 +1,10 @@ -import { debounce } from '../debounce'; +import { debounce } from '../debounce.js'; -jest.useFakeTimers(); +vi.useFakeTimers(); describe('debounce', () => { it('should debounce function calls', () => { - const fn = jest.fn(); + const fn = vi.fn(); const debouncedFn = debounce(fn, 100); // Call the debounced function multiple times within the threshold @@ -13,7 +13,7 @@ describe('debounce', () => { debouncedFn('c'); // Advance time by 99ms - jest.advanceTimersByTime(99); + vi.advanceTimersByTime(99); // The function should not have been called yet expect(fn).not.toHaveBeenCalled(); @@ -22,13 +22,13 @@ describe('debounce', () => { debouncedFn('d'); // Advance time by another 99ms - jest.advanceTimersByTime(99); + vi.advanceTimersByTime(99); // The function should still not have been called yet expect(fn).not.toHaveBeenCalled(); // Advance time by the remaining 1ms - jest.advanceTimersByTime(1); + vi.advanceTimersByTime(1); // The function should have been called once with the last value passed to it expect(fn).toHaveBeenCalledTimes(1); @@ -36,7 +36,7 @@ describe('debounce', () => { }); it('should call the function immediately when the `leading` option is `true`', () => { - const fn = jest.fn(); + const fn = vi.fn(); const debouncedFn = debounce(fn, 100, true); // Call the debounced function multiple times within the threshold @@ -49,7 +49,7 @@ describe('debounce', () => { expect(fn).toHaveBeenCalledWith('a'); // Advance time by 99ms - jest.advanceTimersByTime(99); + vi.advanceTimersByTime(99); // The function should not have been called again yet expect(fn).toHaveBeenCalledTimes(1); @@ -58,13 +58,13 @@ describe('debounce', () => { debouncedFn('d'); // Advance time by another 99ms - jest.advanceTimersByTime(99); + vi.advanceTimersByTime(99); // The function should still not have been called yet expect(fn).toHaveBeenCalledTimes(1); // Advance time by the remaining 1ms - jest.advanceTimersByTime(1); + vi.advanceTimersByTime(1); // The function should have been called once more with the last value passed to it expect(fn).toHaveBeenCalledTimes(2); diff --git a/src/utils/__tests__/debug.test.ts b/src/utils/__tests__/debug.test.ts index 59a40c95..9afd957f 100644 --- a/src/utils/__tests__/debug.test.ts +++ b/src/utils/__tests__/debug.test.ts @@ -1,10 +1,11 @@ -import { debugTime } from '../debug'; +import type { MockInstance } from 'vitest'; +import { debugTime } from '../debug.js'; describe('debug', () => { - let spy: jest.SpyInstance; + let spy: MockInstance; beforeEach(() => { - spy = jest.spyOn(console, 'time'); + spy = vi.spyOn(console, 'time'); }); afterEach(() => { diff --git a/src/utils/__tests__/dom.test.ts b/src/utils/__tests__/dom.test.ts index 3a4ee336..dcd7ce80 100644 --- a/src/utils/__tests__/dom.test.ts +++ b/src/utils/__tests__/dom.test.ts @@ -1,4 +1,4 @@ -import { querySelector, html2Str, IS_BROWSER } from '../dom'; +import { querySelector, html2Str, IS_BROWSER } from '../dom.js'; describe('DOM utils', () => { describe('querySelector', () => { @@ -54,13 +54,13 @@ describe('DOM utils', () => { let parent: HTMLElement; let child: HTMLElement; - beforeEach(() => { + beforeEach(async () => { parent = document.createElement('div'); child = document.createElement('div'); parent.appendChild(child); document.body.appendChild(parent); - jest.spyOn(window, 'getComputedStyle').mockReturnValue({ + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ getPropertyValue: (prop: string) => { if (prop === 'border-top-width') return '10'; if (prop === 'border-left-width') return '10'; @@ -82,11 +82,11 @@ describe('DOM utils', () => { offsetLeft: { value: 150, configurable: true }, clientHeight: { value: 50, configurable: true }, clientWidth: { value: 50, configurable: true }, - scrollIntoView: { value: jest.fn(), configurable: true }, + scrollIntoView: { value: vi.fn(), configurable: true }, }); if (typeof Element.prototype.scrollIntoViewIfNeeded !== 'function') { - require('../dom'); + await import('../dom'); } }); @@ -94,7 +94,7 @@ describe('DOM utils', () => { if (parent.parentNode) { parent.parentNode.removeChild(parent); } - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); it('should center the element when over top and centerIfNeeded is true', () => { diff --git a/src/utils/__tests__/hasNestedFields.test.ts b/src/utils/__tests__/hasNestedFields.test.ts index b04eec79..e570c959 100644 --- a/src/utils/__tests__/hasNestedFields.test.ts +++ b/src/utils/__tests__/hasNestedFields.test.ts @@ -1,5 +1,5 @@ -import { hasNestedFields } from '../hasNestedFields'; -import type { SchemaModel, FieldModel } from '../../models/types'; +import { hasNestedFields } from '../hasNestedFields.js'; +import type { SchemaModel, FieldModel } from '../../models/types.js'; const createMockField = (isPrimitive: boolean, isCircular: boolean = false): FieldModel => ({ schema: { diff --git a/src/utils/__tests__/helpers.test.ts b/src/utils/__tests__/helpers.test.ts index e3ee1225..72fb876b 100644 --- a/src/utils/__tests__/helpers.test.ts +++ b/src/utils/__tests__/helpers.test.ts @@ -8,7 +8,7 @@ import { isLastInArray, isLastProperty, getValueFromMdParsedExtension, -} from '../helpers'; +} from '../helpers.js'; describe('Utils', () => { describe('helpers', () => { diff --git a/src/utils/__tests__/isMobile.test.ts b/src/utils/__tests__/isMobile.test.ts index 2279c9db..b2313519 100644 --- a/src/utils/__tests__/isMobile.test.ts +++ b/src/utils/__tests__/isMobile.test.ts @@ -1,4 +1,4 @@ -import { isMobile } from '../isMobile'; +import { isMobile } from '../isMobile.js'; describe('isMobile', () => { it('should return true for mobile user agent', () => { diff --git a/src/utils/__tests__/loadAndBundleSpec.test.ts b/src/utils/__tests__/loadAndBundleSpec.test.ts index 3cf4da5e..9d7a570c 100644 --- a/src/utils/__tests__/loadAndBundleSpec.test.ts +++ b/src/utils/__tests__/loadAndBundleSpec.test.ts @@ -2,7 +2,7 @@ import * as yaml from 'js-yaml'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -import { loadAndBundleDefinition } from '../loadAndBundleSpec'; +import { loadAndBundleDefinition } from '../loadAndBundleSpec.js'; describe('#loadAndBundleDefinition', () => { it('should load and bundle spec petstore.yaml', async () => { diff --git a/src/utils/__tests__/local-storage.test.ts b/src/utils/__tests__/local-storage.test.ts index 2a8d58f3..8624b17c 100644 --- a/src/utils/__tests__/local-storage.test.ts +++ b/src/utils/__tests__/local-storage.test.ts @@ -1,6 +1,6 @@ -import { toLocalStorage, fromLocalStorage } from '../local-storage'; +import { toLocalStorage, fromLocalStorage } from '../local-storage.js'; -jest.mock('@redocly/theme/core/openapi', () => ({ +vi.mock('@redocly/theme/core/openapi', () => ({ IS_BROWSER: true, })); @@ -12,10 +12,10 @@ describe('local-storage', () => { Object.defineProperty(window, 'localStorage', { value: { - setItem: jest.fn((key: string, value: string) => { + setItem: vi.fn((key: string, value: string) => { mockLocalStorage[key] = value; }), - getItem: jest.fn((key: string) => { + getItem: vi.fn((key: string) => { return mockLocalStorage[key] || null; }), }, @@ -24,7 +24,7 @@ describe('local-storage', () => { }); afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe('toLocalStorage', () => { diff --git a/src/utils/__tests__/object.test.ts b/src/utils/__tests__/object.test.ts index a9f545fe..5361795f 100644 --- a/src/utils/__tests__/object.test.ts +++ b/src/utils/__tests__/object.test.ts @@ -1,4 +1,4 @@ -import { objectHas, objectSet } from '../object'; +import { objectHas, objectSet } from '../object.js'; describe('object utils', () => { let obj; diff --git a/src/utils/__tests__/openapi.test.ts b/src/utils/__tests__/openapi.test.ts index 10fa9333..9e288abd 100644 --- a/src/utils/__tests__/openapi.test.ts +++ b/src/utils/__tests__/openapi.test.ts @@ -1,12 +1,13 @@ +import type { MockInstance } from 'vitest'; import type { OpenAPIParameter, OpenAPIParameterLocation, OpenAPIParameterStyle, OpenAPIMediaType, OpenAPIDefinition, -} from '../../types'; -import type { ExtendedOpenAPIOperation } from '../../services'; -import type { FieldModel, OperationModel } from '../../models/types'; +} from '../../types/index.js'; +import type { ExtendedOpenAPIOperation } from '../../services/index.js'; +import type { FieldModel, OperationModel } from '../../models/types.js'; import { detectType, @@ -23,11 +24,11 @@ import { humanizeNumberRange, getDefinitionName, serializeQueryParameter, -} from '../'; -import { getField } from '../../models'; -import { OpenAPIParser } from '../../services/OpenAPIParser'; -import { getContentWithLegacyExamples } from '../openapi'; -import { normalizeOptions } from '../../services'; +} from '../index.js'; +import { getField } from '../../models/index.js'; +import { OpenAPIParser } from '../../services/OpenAPIParser.js'; +import { getContentWithLegacyExamples } from '../openapi.js'; +import { normalizeOptions } from '../../services/index.js'; describe('Utils', () => { describe('openapi getStatusCode', () => { @@ -434,8 +435,8 @@ describe('Utils', () => { it('should remove query string and hash from url', () => { const originalWindow = { ...window }; - const windowSpy = jest.spyOn(global, 'window', 'get') as jest.SpyInstance< - Window & typeof globalThis + const windowSpy = vi.spyOn(global, 'window', 'get') as unknown as MockInstance< + () => Window & typeof globalThis >; windowSpy.mockImplementation( () => diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts index 9bd93454..507a453d 100644 --- a/src/utils/__tests__/path.test.ts +++ b/src/utils/__tests__/path.test.ts @@ -1,4 +1,4 @@ -import { normalizePath } from '../path'; +import { normalizePath } from '../path.js'; describe('path utils', () => { test('normalizePath should return correct path', () => { diff --git a/src/utils/__tests__/queryString.test.ts b/src/utils/__tests__/queryString.test.ts index 3972f029..c405cf0d 100644 --- a/src/utils/__tests__/queryString.test.ts +++ b/src/utils/__tests__/queryString.test.ts @@ -1,4 +1,4 @@ -import { queryString } from '../queryString'; +import { queryString } from '../queryString.js'; describe('QueryString', () => { describe('parse', () => { diff --git a/src/utils/__tests__/replaceVariables.test.ts b/src/utils/__tests__/replaceVariables.test.ts index d4b54e4b..dd6fc69b 100644 --- a/src/utils/__tests__/replaceVariables.test.ts +++ b/src/utils/__tests__/replaceVariables.test.ts @@ -1,4 +1,4 @@ -import { replaceVariables } from '../replaceVariables'; +import { replaceVariables } from '../replaceVariables.js'; describe('replaceVariables', () => { test('should return the same URL when there are no variables', () => { diff --git a/src/utils/__tests__/simplifyAstStructure.test.ts b/src/utils/__tests__/simplifyAstStructure.test.ts index 490416d2..8aa7bd67 100644 --- a/src/utils/__tests__/simplifyAstStructure.test.ts +++ b/src/utils/__tests__/simplifyAstStructure.test.ts @@ -1,6 +1,6 @@ import { Node } from '@markdoc/markdoc'; -import { simplifyAstStructure } from '../simplifyAstStructure'; +import { simplifyAstStructure } from '../simplifyAstStructure.js'; describe('simplifyAstStructure', () => { it('should remove location from AST nodes', () => { @@ -28,9 +28,8 @@ describe('simplifyAstStructure', () => { it('should return full AST when not a document node type', () => { const mockAst = new Node('text', { content: 'Some text' }); - const openapiNode = { title: 'API Documentation' }; - const result = simplifyAstStructure(mockAst as any, openapiNode); + const result = simplifyAstStructure(mockAst as Node); expect(result).toMatchSnapshot(); }); diff --git a/src/utils/__tests__/sort.test.ts b/src/utils/__tests__/sort.test.ts index 33897e8e..3a181fb5 100644 --- a/src/utils/__tests__/sort.test.ts +++ b/src/utils/__tests__/sort.test.ts @@ -1,4 +1,4 @@ -import { alphabeticallyByProp } from '../sort'; +import { alphabeticallyByProp } from '../sort.js'; describe('alphabeticallyByProp', () => { test('should sort objects by property in ascending order', () => { diff --git a/src/utils/__tests__/string.test.ts b/src/utils/__tests__/string.test.ts index 1757aaae..710778a7 100644 --- a/src/utils/__tests__/string.test.ts +++ b/src/utils/__tests__/string.test.ts @@ -1,6 +1,6 @@ import slugify from 'slugify'; -import { strikethroughText, safeSlugify } from '../string'; +import { strikethroughText, safeSlugify } from '../string.js'; describe('string utils', () => { it('strikethroughText', () => { diff --git a/src/utils/__tests__/theme-helpers.test.ts b/src/utils/__tests__/theme-helpers.test.ts index 2239f769..30512547 100644 --- a/src/utils/__tests__/theme-helpers.test.ts +++ b/src/utils/__tests__/theme-helpers.test.ts @@ -1,4 +1,4 @@ -import { getTypographyCssRulesByComponentName } from '../theme-helpers'; +import { getTypographyCssRulesByComponentName } from '../theme-helpers.js'; describe('getTypographyCssRulesByComponentName', () => { it('should return object with defined css variable for component', () => { diff --git a/src/utils/__tests__/url.test.ts b/src/utils/__tests__/url.test.ts index b5223a4b..0974429c 100644 --- a/src/utils/__tests__/url.test.ts +++ b/src/utils/__tests__/url.test.ts @@ -1,6 +1,14 @@ -import { urlParse, getUrlDirname } from '../url'; +import { urlParse, getUrlDirname } from '../url.js'; describe('url utils', () => { + const originalConsoleError = console.error; + beforeEach(() => { + console.error = vi.fn(); + }); + afterEach(() => { + console.error = originalConsoleError; + }); + describe('urlParse', () => { it('should parse valid URLs', () => { const result = urlParse('https://example.com/path'); diff --git a/src/utils/index.ts b/src/utils/index.ts index 0d89ee14..1fd8f6ef 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -13,7 +13,6 @@ export * from './session-storage.js'; export * from './local-storage.js'; export * from './parameters.js'; export * from './string.js'; -export * from './test-utils.js'; export * from './compose.js'; export * from './saveTextBeforeHeading.js'; export * from './path.js'; diff --git a/src/utils/menu.ts b/src/utils/menu.ts index f509fc95..5e8d84d2 100644 --- a/src/utils/menu.ts +++ b/src/utils/menu.ts @@ -1,5 +1,11 @@ import type { IMenuItem } from '../services/types'; export function isRenderableMenuItem(item: IMenuItem) { - return item.type === 'operation' || item.type === 'schema' || item.type === 'mcp'; + return ( + item.type === 'operation' || + item.type === 'schema' || + item.type === 'tool' || + item.type === 'rsrc' || + item.type === 'prompt' + ); } diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index 44447365..86440dd6 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -10,11 +10,11 @@ import type { OpenAPISchema, OpenAPIServer, Referenced, + OpenAPIParameter, } from '../types/index.js'; import type { ExtendedOpenAPIOperation, OpenAPIParser } from '../services/index.js'; import type { FieldModel, GroupModel } from '../models/index.js'; -import { OpenAPIParameter } from '../types/index.js'; import { deleteEmptyArrayItem, isArrayOfObjects, diff --git a/src/utils/test-utils.ts b/src/utils/test-utils.ts deleted file mode 100644 index a3f5b822..00000000 --- a/src/utils/test-utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* tslint:disable:no-implicit-dependencies */ - -import { objectHas, objectSet } from './object.js'; - -function traverseComponent(root, fn) { - if (!root) { - return; - } - - fn(root); - - if (root.children) { - for (const child of root.children) { - traverseComponent(child, fn); - } - } -} - -export function filterPropsDeep(component: T, paths: string[]): T { - traverseComponent(component, (comp) => { - if (comp.props) { - for (const path of paths) { - if (objectHas(comp.props, path)) { - objectSet(comp.props, path, '<<>>'); - } - } - } - }); - - return component; -} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..36755059 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,61 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "strict": false, + "noEmit": false, + "jsx": "react-jsx", + "experimentalDecorators": true, + "lib": [ + "es2015", + "es2016", + "es2017", + "dom", + "WebWorker.ImportScripts", + "esnext", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noImplicitAny": false, + "useDefineForClassFields": true, + "types": ["node"], + "strictPropertyInitialization": false, + "strictNullChecks": true, + "resolveJsonModule": false, + "rootDir": "src", + "declaration": true, + "outDir": "lib", + "composite": true, + "tsBuildInfoFile": "lib/.tsbuildinfo", + "isolatedModules": true, + "noImplicitReturns": false, + "noImplicitThis": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "useUnknownInCatchVariables": false, + "noFallthroughCasesInSwitch": true, + "allowUnreachableCode": false, + "noUnusedParameters": true, + "noUnusedLocals": true, + "downlevelIteration": true, + "sourceMap": true, + "skipLibCheck": true + }, + "compileOnSave": false, + "exclude": [ + "node_modules", + "bundle", + ".tmp", + "lib", + "redoc", + "bin", + "**/__tests__/**/*", + "src/testProviders.tsx", + "vitest.setup.ts" + ], + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 6960b7da..d8d73503 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,60 +1,9 @@ { + "extends": "./tsconfig.build.json", "compilerOptions": { - "baseUrl": "./", - "strict": false, - "noEmit": false, - "jsx": "react-jsx", - "experimentalDecorators": true, - "lib": [ - "es2015", - "es2016", - "es2017", - "dom", - "WebWorker.ImportScripts", - "esnext", - "DOM.Iterable" - ], - "moduleResolution": "bundler", - "noImplicitAny": false, - "useDefineForClassFields": true, - "target": "ESNext", - "types": ["jest", "node", "@testing-library/jest-dom", "@jest/globals"], - "strictPropertyInitialization": false, - "strictNullChecks": true, - "resolveJsonModule": false, - "rootDir": "src", - "declaration": true, - "module": "ESNext", - "outDir": "lib", - "composite": true, - "tsBuildInfoFile": "lib/.tsbuildinfo", - "isolatedModules": true, - "noImplicitReturns": false, - "noImplicitThis": true, - "forceConsistentCasingInFileNames": true, - "allowJs": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "useUnknownInCatchVariables": false, - "noFallthroughCasesInSwitch": true, - "allowUnreachableCode": false, - "noUnusedParameters": true, - "noUnusedLocals": true, - "downlevelIteration": true, - "sourceMap": true, - "skipLibCheck": true + "types": ["vitest/globals", "node", "@testing-library/jest-dom"], + "resolveJsonModule": true }, - "compileOnSave": false, - "exclude": [ - "node_modules", - "bundle", - ".tmp", - "lib", - "redoc", - "bin", - "**/__tests__/**/*", - "src/testProviders.tsx", - "src/setupTests.ts" - ], - "include": ["src"] + "exclude": ["node_modules", "bundle", ".tmp", "lib", "redoc", "bin"], + "include": ["src", "src/**/*.json"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..49c3e2f8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,57 @@ +import { defineConfig } from 'vitest/config'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export default defineConfig({ + optimizeDeps: { + exclude: ['htmlparser2'], + }, + test: { + globals: true, + environment: 'jsdom', + root: __dirname, + setupFiles: [resolve(__dirname, 'vitest.setup.ts')], + include: ['**/__tests__/**/*.test.[jt]s?(x)', '**/?(*.)+(test).[jt]s?(x)'], + exclude: [ + '**/node_modules/**', + '**/.git/**', + '**/bundle/**', + '**/lib/**', + '**/playground/**', + '**/playwright/**', + '**/scripts/**', + '**/__mocks__/**', + '**/src/icons/**', + ], + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: [ + '**/index.ts', + '**/types.ts', + '**/src/types/**', + '**/src/icons/**', + '**/events/**', + '**/__snapshots__/**', + '**/__fixtures__/**', + '**/__tests__/**', + '**/__mocks__/**', + ], + thresholds: { + statements: 78, + branches: 68, + functions: 75, + lines: 78, + }, + }, + }, + resolve: { + alias: { + path: 'path-browserify', + }, + preserveSymlinks: false, + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 00000000..c72d7d7b --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,58 @@ +import '@testing-library/jest-dom'; +import { styleSheetSerializer } from 'jest-styled-components/serializer'; +import { vi, expect } from 'vitest'; +import { randomUUID } from 'node:crypto'; + +const sortedStyleSheetSerializer: Parameters[0] = { + test: styleSheetSerializer.test, + serialize(val, config, indentation, depth, refs, printer): string { + const result = styleSheetSerializer.serialize(val, config, indentation, depth, refs, printer); + if (typeof result !== 'string') return result; + + const cssEndIndex = result.lastIndexOf('\n\n<'); + if (cssEndIndex === -1) return result; + + const cssBlock = result.slice(0, cssEndIndex); + const htmlBlock = result.slice(cssEndIndex); + const sortedCss = cssBlock + .split(/(?=\.c\d+)/g) + .filter(Boolean) + .sort((a, b) => { + const aMatch = a.match(/^\.c(\d+)/); + const bMatch = b.match(/^\.c(\d+)/); + if (aMatch && bMatch) { + return parseInt(aMatch[1], 10) - parseInt(bMatch[1], 10); + } + return a.localeCompare(b); + }) + .join(''); + + return sortedCss + htmlBlock; + }, +}; + +expect.addSnapshotSerializer(sortedStyleSheetSerializer); + +// Simple fetch mock using vi.fn() +global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + headers: new Headers(), + } as Response), +); + +window.scrollTo = vi.fn(); + +window.crypto.randomUUID = randomUUID as () => `${string}-${string}-${string}-${string}-${string}`; +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); + +// Ensure window is defined for React 19 state updates +if (typeof window !== 'undefined' && !window.document) { + Object.defineProperty(window, 'document', { + value: global.document, + writable: true, + }); +}