diff --git a/src/__tests__/__snapshots__/collection.patternFlyApi.test.ts.snap b/src/__tests__/__snapshots__/collection.patternFlyApi.test.ts.snap index ed8d6a68..1777a931 100644 --- a/src/__tests__/__snapshots__/collection.patternFlyApi.test.ts.snap +++ b/src/__tests__/__snapshots__/collection.patternFlyApi.test.ts.snap @@ -5,19 +5,23 @@ exports[`collectionCallback should match snapshot for collection result 1`] = ` "records": [ { "data": { - "card": { - "category": "css", - "description": "Card css content", - "displayName": "Card", - "id": "api::v1::components::card::css::0", - "path": "https://main.patternfly-org.pages.dev/api/v1/components/Card/css", - "pathSlug": "card", - "section": "components", - "source": "api", - "version": "v1", - }, + "card": [ + { + "category": "css", + "content": "Card css content", + "contentType": "", + "description": "PatternFly variables and tokens for Card CSS.", + "displayName": "Card CSS", + "id": "api::v1::components::card::css", + "path": "https://main.patternfly-org.pages.dev/api/v1/components/Card/css", + "pathSlug": "components-card-css", + "section": "components", + "source": "api", + "version": "v1", + }, + ], }, - "id": "api::v1::components::card::css::0", + "id": "api::v1::components::card::css", "sourceId": "https://main.patternfly-org.pages.dev/api/v1/components/Card/css", "sourceType": "api", }, diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 9aff8168..1c705c69 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -57,9 +57,16 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "props", "css", ], - "crawlCancelMs": 180000, - "crawlIntervalMs": 43200000, "enabled": false, + "schedule": { + "continueOnError": true, + "intervalMs": 604800000, + "repeat": Infinity, + }, + "timeoutMs": 120000, + "traversalPaths": [ + "examples", + ], "versions": "https://main.patternfly-org.pages.dev/api/versions", }, "availableResourceVersions": [ diff --git a/src/__tests__/collection.patternFlyApi.test.ts b/src/__tests__/collection.patternFlyApi.test.ts index f1386894..b4683ff0 100644 --- a/src/__tests__/collection.patternFlyApi.test.ts +++ b/src/__tests__/collection.patternFlyApi.test.ts @@ -7,6 +7,7 @@ import { crawler } from '../collection.patternFlyApi'; import { processDocsFunction } from '../server.getResources'; +import { getOptions } from '../options.context'; jest.mock('../server.getResources'); @@ -46,12 +47,11 @@ describe('collectionCallback', () => { isSuccess: true } ]) - // crawler to leaf at a component facet path ("props") .mockResolvedValueOnce([ { - content: 'Button props content', + content: 'Button react component content with length enough to pass quality scoring...', path: `${BASE}/v1/components/Button`, - resolvedPath: `${BASE}/v1/components/Button/props`, + resolvedPath: `${BASE}/v1/components/Button/react`, isSuccess: true } ]); @@ -76,22 +76,26 @@ describe('collectionCallback', () => { expect(keys.length).toBe(1); const key: any = keys[0]; + expect(key).toBe('button'); + expect(first).toMatchObject({ - sourceId: `${BASE}/v1/components/Button/props` + sourceId: `${BASE}/v1/components/Button/react` }); - expect(first.data[key]).toMatchObject({ + expect(Array.isArray(first.data[key])).toBe(true); + + expect(first.data[key][0]).toMatchObject({ displayName: 'Button', - pathSlug: 'button', + pathSlug: 'components-button-react', source: 'api', version: 'v1', section: 'components', - category: 'props', - path: `${BASE}/v1/components/Button/props` + category: 'react', + path: `${BASE}/v1/components/Button/react` }); }); - it('should uses kind "doc" when a facet is not a componentPath', async () => { + it('should use an extrapolated category', async () => { // getVersions to ["v1"] mockedProcessDocsFunction .mockResolvedValueOnce([ @@ -118,15 +122,15 @@ describe('collectionCallback', () => { const rec: any = result.records[0]; // id encodes version, section, item, kind, and index - expect(rec?.id).toMatch(/^api::v1::components::card::doc::0$/); + expect(rec?.id).toMatch(/^api::v1::components::card::overview$/); const key: any = rec?.data ? Object.keys(rec.data)[0] : ''; expect(key).toBe('card'); - expect(rec?.data?.[key]).toMatchObject({ + expect(rec?.data?.[key]).toContainEqual(expect.objectContaining({ displayName: 'Card', - category: 'doc' - }); + category: 'overview' + })); }); it('should match snapshot for collection result', async () => { @@ -205,7 +209,7 @@ describe('crawler', () => { expect(res).toHaveLength(1); expect(res[0]?.content).toBe('some content'); - expect(mockedProcessDocsFunction).toHaveBeenCalledTimes(2); + expect(mockedProcessDocsFunction).toHaveBeenCalledTimes(3); }); it('handles component paths and terminates recursion', async () => { @@ -266,6 +270,16 @@ describe('crawler', () => { expect(res.length).toBeGreaterThanOrEqual(1); expect(mockedProcessDocsFunction).toHaveBeenCalledWith(['https://api.com/v1']); }); + + it('aborts crawling early when signal is aborted', async () => { + const controller = new AbortController(); + + controller.abort(); + const res = await crawler(['https://api.com/v1'], { signal: controller.signal }); + + expect(res).toEqual([]); + expect(mockedProcessDocsFunction).not.toHaveBeenCalled(); + }); }); describe('apiSpider', () => { @@ -288,7 +302,7 @@ describe('apiSpider', () => { expect(res).toEqual([]); }); - it('returns ApiContent[] with metadata shape', async () => { + it('returns ApiContent[] shape', async () => { mockedProcessDocsFunction .mockResolvedValueOnce([ { @@ -311,14 +325,37 @@ describe('apiSpider', () => { expect(res.length).toBeGreaterThan(0); expect(res[0]).toMatchObject({ - url: 'https://main.patternfly-org.pages.dev/api/v1/section/item/facet', - content: 'leaf content', - semanticContext: { - version: 'v1', - section: 'section', - item: 'item', - facet: 'facet' + path: 'https://main.patternfly-org.pages.dev/api/v1', + resolvedPath: 'https://main.patternfly-org.pages.dev/api/v1/section/item/facet', + content: 'leaf content' + }); + }); + + it('handles crawl timeout gracefully in apiSpider', async () => { + const options = getOptions(); + + mockedProcessDocsFunction + .mockResolvedValueOnce([ + { + content: JSON.stringify(['v1']), + path: 'https://main.patternfly-org.pages.dev/api/versions', + resolvedPath: 'https://main.patternfly-org.pages.dev/api/versions', + isSuccess: true + } + ]) + .mockImplementation(() => new Promise(() => {})); + + const res = await apiSpider({ + ...options, + patternflyOptions: { + ...options.patternflyOptions, + api: { + ...options.patternflyOptions.api, + timeoutMs: 20 + } } }); + + expect(res).toEqual([]); }); }); diff --git a/src/__tests__/collection.patternFlyApiHelpers.test.ts b/src/__tests__/collection.patternFlyApiHelpers.test.ts new file mode 100644 index 00000000..0a6ae48f --- /dev/null +++ b/src/__tests__/collection.patternFlyApiHelpers.test.ts @@ -0,0 +1,822 @@ +import { + calculateContentQualityScore, + extractApiDescription, + extractApiDisplayName, + extractApiName, + formatSlugToTitle, + getApiFallbackDescription, + getLiveExampleCount, + hasEmptyFileCodeFence, + hasLiveExample, + isRawImport, + normalizeSlug +} from '../collection.patternFlyApiHelpers'; + +describe('isRawImport', () => { + it.each([ + { + description: 'default import with ?raw query', + input: "import Button from './Button.tsx?raw'", + expected: true + }, + { + description: 'named import with ?raw query', + input: "import { Button } from './Button.tsx?raw'", + expected: true + }, + { + description: 'multiple named imports with ?raw query', + input: "import { Button, Card, Modal } from './components?raw'", + expected: true + }, + { + description: 'namespace import with ?raw query', + input: "import * as React from './React?raw'", + expected: true + }, + { + description: 'combined default and named imports with ?raw query', + input: "import React, { useState } from './React?raw'", + expected: true + }, + { + description: 'double quotes with ?raw query', + input: 'import Button from "./Button.tsx?raw"', + expected: true + }, + { + description: 'case-insensitive import statement with uppercase ?RAW', + input: "IMPORT Button FROM './Button.tsx?RAW'", + expected: true + }, + { + description: 'multiline import statement with ?raw query', + input: "import {\n Button,\n Card\n} from './components?raw'", + expected: true + }, + { + description: 'standard import without ?raw query', + input: "import Button from './Button.tsx'", + expected: false + }, + { + description: 'side-effect import without clause', + input: "import './styles.css?raw'", + expected: false + }, + { + description: 'string without import statement', + input: 'const file = "./Button.tsx?raw";', + expected: false + }, + { + description: 'empty string', + input: '', + expected: false + } + ])('should detect raw imports, $description', ({ input, expected }) => { + expect(isRawImport(input)).toBe(expected); + }); +}); + +describe('hasLiveExample', () => { + it.each([ + { + description: 'self-closing LiveExample tag', + input: '', + expected: true + }, + { + description: 'opening LiveExample tag with attributes', + input: '', + expected: true + }, + { + description: 'case-insensitive liveexample tag', + input: '', + expected: true + }, + { + description: 'LiveExample tag with multiline attributes', + input: '', + expected: true + }, + { + description: 'LiveExample tag without attributes', + input: '', + expected: true + }, + { + description: 'text without LiveExample tag', + input: '
Regular HTML component
', + expected: false + }, + { + description: 'extended component name without word boundary match', + input: '', + expected: false + }, + { + description: 'empty string', + input: '', + expected: false + } + ])('should detect LiveExample tags, $description', ({ input, expected }) => { + expect(hasLiveExample(input)).toBe(expected); + }); +}); + +describe('getLiveExampleCount', () => { + it.each([ + { + description: 'zero occurrences in plain text', + input: 'Plain text without examples', + expected: 0 + }, + { + description: 'single self-closing tag', + input: '', + expected: 1 + }, + { + description: 'multiple tags with mixed casing', + input: '\n\n', + expected: 3 + }, + { + description: 'tag with child elements', + input: 'child content', + expected: 1 + }, + { + description: 'empty string', + input: '', + expected: 0 + } + ])('should count LiveExample occurrences, $description', ({ input, expected }) => { + expect(getLiveExampleCount(input)).toBe(expected); + }); +}); + +describe('hasEmptyFileCodeFence', () => { + it.each([ + { + description: 'empty code fence with file attribute', + input: '```ts file="./ButtonBasic.tsx"\n```', + expected: true + }, + { + description: 'empty code fence with file attribute and inner whitespace', + input: '```tsx file="./Button.tsx" \n ```', + expected: true + }, + { + description: 'empty code fence with language only', + input: '```ts\n```', + expected: true + }, + { + description: 'empty code fence with no language', + input: '```\n```', + expected: true + }, + { + description: 'empty code fence with trailing whitespace inside', + input: '```js\n \n```', + expected: true + }, + { + description: 'non-empty code fence with file attribute', + input: '```ts file="./Button.tsx"\nconst button = true;\n```', + expected: false + }, + { + description: 'non-empty code fence with language', + input: '```ts\nconst total = 42;\n```', + expected: false + }, + { + description: 'plain text without code fences', + input: 'Just regular documentation text', + expected: false + }, + { + description: 'empty string', + input: '', + expected: false + } + ])('should detect empty file code fences, $description', ({ input, expected }) => { + expect(hasEmptyFileCodeFence(input)).toBe(expected); + }); +}); + +describe('calculateContentQualityScore', () => { + it.each([ + { + description: 'undefined content returns baseScore', + content: undefined, + options: undefined, + expected: 1 + }, + { + description: 'null content returns baseScore', + content: null, + options: undefined, + expected: 1 + }, + { + description: 'boolean content returns baseScore', + content: true, + options: undefined, + expected: 1 + }, + { + description: 'empty string returns baseScore', + content: '', + options: undefined, + expected: 1 + }, + { + description: 'whitespace-only string returns baseScore', + content: ' \n\t ', + options: undefined, + expected: 1 + }, + { + description: 'category equals examples skips quality checks', + content: 'short', + options: { category: 'examples' }, + expected: 1 + }, + { + description: 'high quality content exceeding minimum characters', + content: 'A'.repeat(200), + options: undefined, + expected: 1 + }, + { + description: 'short content without code block receives length penalty', + content: 'Short content description.', + options: undefined, + expected: 0.97 + }, + { + description: 'short content with code block does not receive length penalty', + content: 'Short:\n```ts\nconst value = 1;\n```', + options: undefined, + expected: 1 + }, + { + description: 'invalid JSON-like content receives JSON penalty and length penalty', + content: '{ invalid: json content }', + options: undefined, + expected: 0.94 + }, + { + description: 'valid JSON content without length penalty when over minimum length', + content: JSON.stringify({ description: 'A'.repeat(160) }), + options: undefined, + expected: 1 + }, + { + description: 'content with raw import receives penalty', + content: `import Button from './Button?raw';\n${'A'.repeat(160)}`, + options: undefined, + expected: 0.97 + }, + { + description: 'content with multiple LiveExample tags reduces score per tag', + content: `\n\n${'A'.repeat(160)}`, + options: undefined, + expected: 0.94 + }, + { + description: 'content with empty file code fence over minimum characters', + content: `${'A'.repeat(160)}\n\`\`\`ts file="./Button.tsx"\n\`\`\``, + options: undefined, + expected: 0.97 + }, + { + description: 'content with empty file code fence under minimum characters receives double penalty', + content: '```ts file="./Button.tsx"\n```', + options: undefined, + expected: 0.94 + }, + { + description: 'numeric content converted to string and evaluated', + content: 12345, + options: undefined, + expected: 0.97 + }, + { + description: 'custom baseScore and qualityReduction options', + content: 'Short text', + options: { baseScore: 0.8, qualityReduction: 0.1 }, + expected: 0.7 + }, + { + description: 'custom minCharacters option satisfied', + content: 'A'.repeat(60), + options: { minCharacters: 50 }, + expected: 1 + }, + { + description: 'score clamped to 0 when penalties exceed baseScore', + content: '{ invalid json }', + options: { baseScore: 0.05, qualityReduction: 0.1 }, + expected: 0 + }, + { + description: 'score clamped to 1 when baseScore exceeds 1', + content: 'A'.repeat(200), + options: { baseScore: 1.5 }, + expected: 1 + } + ])('should calculate quality score, $description', ({ content, options, expected }: any) => { + expect(calculateContentQualityScore(content, options)).toBe(expected); + }); +}); + +describe('normalizeSlug', () => { + it.each([ + { + description: 'PascalCase component name', + input: 'Button', + expected: 'button' + }, + { + description: 'multi-word PascalCase component name', + input: 'ActionList', + expected: 'action-list' + }, + { + description: 'multi-word PascalCase with three words', + input: 'ModalBoxHeader', + expected: 'modal-box-header' + }, + { + description: 'acronym exception CSS', + input: 'CSS', + expected: 'css' + }, + { + description: 'acronym exception HTML', + input: 'HTML', + expected: 'html' + }, + { + description: 'acronym exception AI', + input: 'AI', + expected: 'ai' + }, + { + description: 'acronym exception MCP', + input: 'MCP', + expected: 'mcp' + }, + { + description: 'acronym exception CLI', + input: 'CLI', + expected: 'cli' + }, + { + description: 'acronym exception UXD', + input: 'UXD', + expected: 'uxd' + }, + { + description: 'acronym exception UI', + input: 'UI', + expected: 'ui' + }, + { + description: 'acronym exception API', + input: 'API', + expected: 'api' + }, + { + description: 'acronym exception FAQ and FAQS', + input: 'FAQS', + expected: 'faqs' + }, + { + description: 'acronym exception ARIA', + input: 'ARIA', + expected: 'aria' + }, + { + description: 'acronym exception RTL', + input: 'RTL', + expected: 'rtl' + }, + { + description: 'already kebab-cased string', + input: 'action-list', + expected: 'action-list' + }, + { + description: 'snake_cased string', + input: 'action_list_item', + expected: 'action-list-item' + }, + { + description: 'mixed underscores and multiple hyphens', + input: 'action__list--item_sub', + expected: 'action-list-item-sub' + }, + { + description: 'leading and trailing whitespace', + input: ' Button ', + expected: 'button' + }, + { + description: 'camelCase string with leading lowercase', + input: 'actionList', + expected: 'actionlist' + }, + { + description: 'empty string', + input: '', + expected: '' + } + ])('should normalize slug, $description', ({ input, expected }) => { + expect(normalizeSlug(input)).toBe(expected); + }); +}); + +describe('formatSlugToTitle', () => { + it.each([ + { + description: 'empty slug returns default title', + slug: '', + section: undefined, + expected: 'PatternFly API' + }, + { + description: 'single word slug', + slug: 'button', + section: undefined, + expected: 'Button' + }, + { + description: 'kebab-case slug', + slug: 'action-list', + section: undefined, + expected: 'Action List' + }, + { + description: 'slug with acronyms', + slug: 'css-variables', + section: undefined, + expected: 'CSS Variables' + }, + { + description: 'slug with multiple acronyms', + slug: 'html-and-css-api', + section: undefined, + expected: 'HTML And CSS API' + }, + { + description: 'slug with all supported acronyms', + slug: 'ai-cli-mcp-uxd-ui-faq-faqs-aria-rtl', + section: undefined, + expected: 'AI CLI MCP UXD UI FAQ FAQS ARIA RTL' + }, + { + description: 'compound slug with underscore separator', + slug: 'ai-assisted-development_ai-assisted-code-migration', + section: undefined, + expected: 'AI Assisted Development: AI Assisted Code Migration' + }, + { + description: 'compound slug with multiple underscore sections', + slug: 'section-one_section-two_section-three', + section: undefined, + expected: 'Section One: Section Two: Section Three' + }, + { + description: 'overview slug with section', + slug: 'overview', + section: 'components', + expected: 'Components Overview' + }, + { + description: 'overview slug with section containing acronym', + slug: 'overview', + section: 'ai-assist', + expected: 'AI Assist Overview' + }, + { + description: 'overview slug with multi-word section', + slug: 'overview', + section: 'user-interface-patterns', + expected: 'User Interface Patterns Overview' + }, + { + description: 'overview slug without section', + slug: 'overview', + section: undefined, + expected: 'Overview' + } + ])('should format slug to title, $description', ({ slug, section, expected }) => { + expect(formatSlugToTitle(slug, section)).toBe(expected); + }); +}); + +describe('extractApiDisplayName', () => { + it.each([ + { + description: 'props category with valid JSON containing name property', + content: JSON.stringify({ name: 'ButtonProps', props: {} }), + context: { category: 'props', slug: 'button-props' }, + expected: 'ButtonProps' + }, + { + description: 'props category with valid JSON without name property falls back to slug', + content: JSON.stringify({ props: {} }), + context: { category: 'props', slug: 'button-props' }, + expected: 'Button Props' + }, + { + description: 'props category with invalid JSON falls back to slug', + content: '{ invalid json', + context: { category: 'props', slug: 'button-props' }, + expected: 'Button Props' + }, + { + description: 'props category with markdown H1 heading', + content: '# Custom Button Props\nSome description', + context: { category: 'props', slug: 'button-props' }, + expected: 'Custom Button Props' + }, + { + description: 'css category with slug not containing CSS appends CSS', + content: '[]', + context: { category: 'css', slug: 'button', section: 'components' }, + expected: 'Button CSS' + }, + { + description: 'css category with slug already containing CSS does not append CSS', + content: '[]', + context: { category: 'css', slug: 'button-CSS', section: 'components' }, + expected: 'Button CSS' + }, + { + description: 'markdown content with H1 heading', + content: '# Card Component\nDescription text', + context: { slug: 'card' }, + expected: 'Card Component' + }, + { + description: 'markdown content with H1 overview and section', + content: '# Overview\nOverview body', + context: { slug: 'overview', section: 'components' }, + expected: 'Components Overview' + }, + { + description: 'markdown content with H1 Overview without section', + content: '# Overview\nOverview body', + context: { slug: 'overview' }, + expected: 'Overview' + }, + { + description: 'markdown content without H1 heading falls back to slug', + content: '## Subheading\nParagraph text without H1', + context: { slug: 'data-list', section: 'components' }, + expected: 'Data List' + }, + { + description: 'empty content falls back to slug and section', + content: '', + context: { slug: 'alert-group', section: 'components' }, + expected: 'Alert Group' + }, + { + description: 'undefined content and context return default', + content: undefined, + context: undefined, + expected: 'PatternFly API' + }, + { + description: 'null context explicitly passed returns default', + content: undefined, + context: null as any, + expected: 'PatternFly API' + } + ])('should extract API display name, $description', ({ content, context, expected }: any) => { + expect(extractApiDisplayName(content, context)).toBe(expected); + }); +}); + +describe('getApiFallbackDescription', () => { + it.each([ + { + description: 'props category', + displayName: 'Button', + category: 'props', + expected: 'PatternFly React component props and TypeScript interfaces for Button.' + }, + { + description: 'css category without css in displayName', + displayName: 'Button', + category: 'css', + expected: 'PatternFly CSS variables and tokens for Button.' + }, + { + description: 'css category with uppercase CSS in displayName', + displayName: 'Button CSS', + category: 'css', + expected: 'PatternFly variables and tokens for Button CSS.' + }, + { + description: 'css category with lowercase css in displayName', + displayName: 'button css tokens', + category: 'css', + expected: 'PatternFly variables and tokens for button css tokens.' + }, + { + description: 'html category', + displayName: 'Button', + category: 'html', + expected: 'PatternFly HTML examples and markup structure for Button.' + }, + { + description: 'html-demos category', + displayName: 'Card', + category: 'html-demos', + expected: 'PatternFly HTML examples and markup structure for Card.' + }, + { + description: 'react category', + displayName: 'Button', + category: 'react', + expected: 'PatternFly React component examples and demos for Button.' + }, + { + description: 'react-demos category', + displayName: 'Modal', + category: 'react-demos', + expected: 'PatternFly React component examples and demos for Modal.' + }, + { + description: 'examples category', + displayName: 'Button', + category: 'examples', + expected: 'PatternFly Button examples and demos.' + }, + { + description: 'doc category', + displayName: 'Button', + category: 'doc', + expected: 'PatternFly documentation and guidelines for Button.' + }, + { + description: 'unrecognized category defaults to doc format', + displayName: 'Button', + category: 'custom', + expected: 'PatternFly documentation and guidelines for Button.' + }, + { + description: 'default arguments without parameters', + displayName: undefined, + category: undefined, + expected: 'PatternFly documentation and guidelines for .' + } + ])('should provide fallback description, $description', ({ displayName, category, expected }: any) => { + expect(getApiFallbackDescription(displayName, category)).toBe(expected); + }); +}); + +describe('extractApiDescription', () => { + it.each([ + { + description: 'props category returns fallback', + content: '# Title\nValid paragraph line exceeding twenty characters in length.', + context: { displayName: 'Button', category: 'props' }, + expected: 'PatternFly React component props and TypeScript interfaces for Button.' + }, + { + description: 'css category returns fallback', + content: '# Title\nValid paragraph line exceeding twenty characters in length.', + context: { displayName: 'Button', category: 'css' }, + expected: 'PatternFly CSS variables and tokens for Button.' + }, + { + description: 'detailType equals examples returns fallback', + content: '# Title\nValid paragraph line exceeding twenty characters in length.', + context: { displayName: 'Button', detailType: 'examples' }, + expected: 'PatternFly Button examples and demos.' + }, + { + description: 'markdown content uses first valid paragraph', + content: '# Button\n\nA button is a clickable interactive element that triggers an action.', + context: { displayName: 'Button', category: 'doc' }, + expected: 'A button is a clickable interactive element that triggers an action.' + }, + { + description: 'markdown formatting like bold, italics, inline code, and links are stripped', + content: '# Title\n\nA **button** communicates an [action](https://patternfly.org) to be *performed* with `onClick`.', + context: { displayName: 'Button', category: 'doc' }, + expected: 'A button communicates an action to be performed with onClick.' + }, + { + description: 'HTML links and tags are stripped or converted to inner text', + content: '# Title\n\nA button with badge indicator.', + context: { displayName: 'Button', category: 'doc' }, + expected: 'A button with badge indicator.' + }, + { + description: 'trailing colon in paragraph is replaced with period', + content: '# Title\n\nHere is a list of components and features available for use:', + context: { displayName: 'Button', category: 'doc' }, + expected: 'Here is a list of components and features available for use.' + }, + { + description: 'paragraph exceeding 200 characters is truncated', + content: `# Title\n\n${'This is a long description sentence describing the component. '.repeat(5)}`, + context: { displayName: 'Button', category: 'doc' }, + expected: `${'This is a long description sentence describing the component. '.repeat(5).trim().slice(0, 197)}...` + }, + { + description: 'content with imports and code blocks filtered out before extracting prose', + content: "import React from 'react';\n```tsx\n