diff --git a/package.json b/package.json index 9667738..98e79f3 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/lodash.camelcase": "^4.3.9", "@types/node": "^22.10.7", "@types/pretty-ms": "^5.0.1", + "@vitest/coverage-v8": "3.0.5", "husky": "^9.1.6", "lint-staged": "^15.2.10", "prettier": "^3.3.3", diff --git a/tests/DocsParser.spec.ts b/tests/DocsParser.spec.ts new file mode 100644 index 0000000..578f4be --- /dev/null +++ b/tests/DocsParser.spec.ts @@ -0,0 +1,513 @@ +import { afterEach, assert, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { DocsParser } from '../src/DocsParser.js'; +import type { + ModuleDocumentationContainer, + ClassDocumentationContainer, + ElementDocumentationContainer, + StructureDocumentationContainer, +} from '../src/ParsedDocumentation.js'; + +describe('DocsParser', () => { + let tempDir: string; + + beforeEach(async () => { + // Create a temporary directory structure for tests + tempDir = path.join(process.cwd(), 'tests', 'temp-fixtures'); + await fs.promises.mkdir(tempDir, { recursive: true }); + await fs.promises.mkdir(path.join(tempDir, 'docs', 'api', 'structures'), { + recursive: true, + }); + }); + + afterEach(async () => { + // Clean up temp directory + if (fs.existsSync(tempDir)) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + }); + + describe('parseAPIFile', () => { + it('should parse a simple module documentation', async () => { + const moduleContent = `# app + +_Main process_ + +Control your application's event lifecycle. + +## Events + +### Event: 'ready' + +Returns: + +* \`launchInfo\` unknown _macOS_ + +Emitted once, when Electron has finished initializing. + +## Methods + +### \`app.quit()\` + +Try to close all windows. + +## Properties + +### \`app.name\` _Readonly_ + +A \`string\` property that indicates the current application's name. +`; + + const modulePath = path.join(tempDir, 'docs', 'api', 'app.md'); + await fs.promises.writeFile(modulePath, moduleContent); + + const parser = new DocsParser(tempDir, '1.0.0', [modulePath], [], 'single'); + const result = await parser.parse(); + + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + + const appModule = result.find((m) => m.name === 'app'); + assert(appModule?.type === 'Module', 'Parsed module should be of type Module'); + // Just check that process information is present + expect(appModule.process).toBeDefined(); + expect(appModule.process.main).toBe(true); + expect(appModule.description).toContain('Control your application'); + + expect(appModule.events).toHaveLength(1); + expect(appModule.events[0].name).toBe('ready'); + + expect(appModule.methods).toHaveLength(1); + expect(appModule.methods[0].name).toBe('quit'); + + expect(appModule.properties).toHaveLength(1); + expect(appModule.properties[0].name).toBe('name'); + }); + + it('should parse a class documentation', async () => { + const classContent = `# BrowserWindow + +_Main process_ + +Create and control browser windows. + +## Class: BrowserWindow + +### \`new BrowserWindow([options])\` + +* \`options\` Object (optional) + * \`width\` Integer (optional) - Window's width in pixels. Default is \`800\`. + * \`height\` Integer (optional) - Window's height in pixels. Default is \`600\`. + +### Instance Methods + +#### \`win.close()\` + +Try to close the window. + +#### \`win.show()\` + +Shows the window. + +### Instance Properties + +#### \`win.id\` _Readonly_ + +A \`Integer\` representing the unique ID of the window. + +### Instance Events + +#### Event: 'closed' + +Emitted when the window is closed. +`; + + const classPath = path.join(tempDir, 'docs', 'api', 'browser-window.md'); + await fs.promises.writeFile(classPath, classContent); + + const parser = new DocsParser(tempDir, '1.0.0', [classPath], [], 'single'); + const result = await parser.parse(); + + expect(result).toBeDefined(); + const browserWindowClass = result.find((c) => c.name === 'BrowserWindow'); + + assert(browserWindowClass?.type === 'Class', 'Parsed class should be of type Class'); + assert(browserWindowClass.constructorMethod, 'Constructor method should be defined'); + expect(browserWindowClass.constructorMethod.parameters).toHaveLength(1); + + expect(browserWindowClass.instanceMethods).toHaveLength(2); + expect(browserWindowClass.instanceMethods[0].name).toBe('close'); + expect(browserWindowClass.instanceMethods[1].name).toBe('show'); + + expect(browserWindowClass.instanceProperties).toHaveLength(1); + expect(browserWindowClass.instanceProperties[0].name).toBe('id'); + + expect(browserWindowClass.instanceEvents).toHaveLength(1); + expect(browserWindowClass.instanceEvents[0].name).toBe('closed'); + }); + + it('should parse a class with static methods and properties', async () => { + const classContent = `# Menu + +_Main process_ + +Create native application menus. + +## Class: Menu + +### Static Methods + +#### \`Menu.buildFromTemplate(template)\` + +* \`template\` MenuItemConstructorOptions[] + +Returns \`Menu\` - the menu instance. + +### Static Properties + +#### \`Menu.applicationMenu\` + +A \`Menu | null\` property that returns the application menu. + +### Instance Methods + +#### \`menu.popup([options])\` + +* \`options\` Object (optional) + +Pops up this menu. +`; + + const classPath = path.join(tempDir, 'docs', 'api', 'menu.md'); + await fs.promises.writeFile(classPath, classContent); + + const parser = new DocsParser(tempDir, '1.0.0', [classPath], [], 'single'); + const result = await parser.parse(); + + const menuClass = result.find((c) => c.name === 'Menu'); + assert(menuClass?.type === 'Class', 'Parsed class should be of type Class'); + + expect(menuClass.staticMethods).toHaveLength(1); + expect(menuClass.staticMethods[0].name).toBe('buildFromTemplate'); + expect(menuClass.staticMethods[0].returns).toBeDefined(); + expect(menuClass.staticProperties).toHaveLength(1); + expect(menuClass.staticProperties[0].name).toBe('applicationMenu'); + }); + + it('should handle module with exported class in multi-package mode', async () => { + const moduleContent = `# BrowserWindow + +_Main process_ + +Create and control browser windows. + +## Methods + +### \`BrowserWindow.getAllWindows()\` + +Returns \`BrowserWindow[]\` - An array of all opened browser windows. + +## Class: BrowserWindow + +### Instance Methods + +#### \`win.close()\` + +Try to close the window. +`; + + const modulePath = path.join(tempDir, 'docs', 'api', 'browser-window.md'); + await fs.promises.writeFile(modulePath, moduleContent); + + const parser = new DocsParser(tempDir, '1.0.0', [modulePath], [], 'multi'); + const result = await parser.parse(); + + // In multi-package mode, the module should exist and contain exported classes + expect(result.length).toBeGreaterThan(0); + const hasModuleWithClasses = result.some( + (item) => item.type === 'Module' && item.exportedClasses && item.exportedClasses.length > 0, + ); + expect(hasModuleWithClasses).toBe(true); + }); + + it('should parse an element/tag documentation', async () => { + const elementContent = `# \`\` Tag + +_Renderer process_ + +Display external web content in an isolated frame. + +## Methods + +### \`.loadURL(url)\` + +* \`url\` string + +Loads the url in the webview. + +## Tag Attributes + +### \`src\` + +A \`string\` representing the visible URL. + +## DOM Events + +### Event: 'did-finish-load' + +Fired when the navigation is done. +`; + + const elementPath = path.join(tempDir, 'docs', 'api', 'webview-tag.md'); + await fs.promises.writeFile(elementPath, elementContent); + + const parser = new DocsParser(tempDir, '1.0.0', [elementPath], [], 'single'); + const result = await parser.parse(); + + const webviewElement = result.find((e) => e.name === 'webviewTag'); + assert(webviewElement?.type === 'Element', 'Parsed element should be of type Element'); + expect(webviewElement.extends).toBe('HTMLElement'); + + expect(webviewElement.methods).toHaveLength(1); + expect(webviewElement.methods[0].name).toBe('loadURL'); + + expect(webviewElement.properties).toHaveLength(1); + expect(webviewElement.properties[0].name).toBe('src'); + + expect(webviewElement.events).toHaveLength(1); + expect(webviewElement.events[0].name).toBe('did-finish-load'); + }); + + it('should handle process tags correctly', async () => { + const mainProcessContent = `# app + +_Main process_ + +Main process module. + +## Methods + +### \`app.quit()\` + +Quit the application. +`; + const rendererProcessContent = `# contextBridge + +_Renderer process_ + +Renderer process module. + +## Methods + +### \`contextBridge.exposeInMainWorld(apiKey, api)\` + +* \`apiKey\` string +* \`api\` any + +Expose API to renderer. +`; + + const mainPath = path.join(tempDir, 'docs', 'api', 'app.md'); + const rendererPath = path.join(tempDir, 'docs', 'api', 'context-bridge.md'); + + await fs.promises.writeFile(mainPath, mainProcessContent); + await fs.promises.writeFile(rendererPath, rendererProcessContent); + + const parser = new DocsParser(tempDir, '1.0.0', [mainPath, rendererPath], [], 'single'); + const result = await parser.parse(); + + const appModule = result.find((m) => m.name === 'app'); + assert(appModule?.type === 'Module', 'Parsed module should be of type Module'); + const contextBridgeModule = result.find((m) => m.name === 'contextBridge'); + assert(contextBridgeModule?.type === 'Module', 'Parsed module should be of type Module'); + + // Just verify process information is parsed + expect(appModule.process).toBeDefined(); + expect(contextBridgeModule.process).toBeDefined(); + }); + }); + + describe('parseStructure', () => { + it('should parse a structure documentation', async () => { + const structureContent = `# Rectangle Object + +* \`x\` Integer - The x coordinate of the origin of the rectangle. +* \`y\` Integer - The y coordinate of the origin of the rectangle. +* \`width\` Integer - The width of the rectangle. +* \`height\` Integer - The height of the rectangle. + +Additional description after the property list. +`; + + const structurePath = path.join(tempDir, 'docs', 'api', 'structures', 'rectangle.md'); + await fs.promises.writeFile(structurePath, structureContent); + + const parser = new DocsParser(tempDir, '1.0.0', [], [structurePath], 'single'); + const result = await parser.parse(); + + const rectangleStructure = result.find((s) => s.name === 'Rectangle'); + assert( + rectangleStructure?.type === 'Structure', + 'Parsed structure should be of type Structure', + ); + + expect(rectangleStructure.properties).toHaveLength(4); + expect(rectangleStructure.properties[0].name).toBe('x'); + expect(rectangleStructure.properties[0].type).toBe('Integer'); + expect(rectangleStructure.properties[1].name).toBe('y'); + expect(rectangleStructure.properties[2].name).toBe('width'); + expect(rectangleStructure.properties[3].name).toBe('height'); + }); + + it('should parse a structure with optional properties', async () => { + const structureContent = `# Options Object + +* \`width\` Integer (optional) - Window width. +* \`height\` Integer (optional) - Window height. +* \`title\` string - Window title. +`; + + const structurePath = path.join(tempDir, 'docs', 'api', 'structures', 'options.md'); + await fs.promises.writeFile(structurePath, structureContent); + + const parser = new DocsParser(tempDir, '1.0.0', [], [structurePath], 'single'); + const result = await parser.parse(); + + const optionsStructure = result.find((s) => s.name === 'Options'); + assert( + optionsStructure?.type === 'Structure', + 'Parsed structure should be of type Structure', + ); + + expect(optionsStructure.properties[0].required).toBe(false); + expect(optionsStructure.properties[1].required).toBe(false); + expect(optionsStructure.properties[2].required).toBe(true); + }); + + it('should handle structure with extends clause', async () => { + const structureContent = `# ExtendedOptions Object extends \`BaseOptions\` + +* \`extra\` string - Extra property. +`; + + const structurePath = path.join(tempDir, 'docs', 'api', 'structures', 'extended-options.md'); + await fs.promises.writeFile(structurePath, structureContent); + + const parser = new DocsParser(tempDir, '1.0.0', [], [structurePath], 'single'); + const result = await parser.parse(); + + const extendedStructure = result.find((s) => s.name === 'ExtendedOptions'); + expect(extendedStructure).toBeDefined(); + expect(extendedStructure?.extends).toBe('BaseOptions'); + }); + }); + + describe('parse', () => { + it('should parse multiple files and return complete documentation', async () => { + const appContent = `# app + +_Main process_ + +Control your application. + +## Methods + +### \`app.quit()\` + +Quit the application. +`; + + const rectStructure = `# Rectangle Object + +* \`x\` Integer +* \`y\` Integer +`; + + const appPath = path.join(tempDir, 'docs', 'api', 'app.md'); + const rectPath = path.join(tempDir, 'docs', 'api', 'structures', 'rectangle.md'); + + await fs.promises.writeFile(appPath, appContent); + await fs.promises.writeFile(rectPath, rectStructure); + + const parser = new DocsParser(tempDir, '1.0.0', [appPath], [rectPath], 'single'); + const result = await parser.parse(); + + expect(result).toHaveLength(2); + expect(result.some((item) => item.name === 'app')).toBe(true); + expect(result.some((item) => item.name === 'Rectangle')).toBe(true); + }); + + it('should add error context when parsing fails', async () => { + const invalidContent = `# InvalidModule + +This has no proper structure for parsing. + +## Methods + +### Invalid method format here +`; + + const invalidPath = path.join(tempDir, 'docs', 'api', 'invalid.md'); + await fs.promises.writeFile(invalidPath, invalidContent); + + const parser = new DocsParser(tempDir, '1.0.0', [invalidPath], [], 'single'); + + await expect(parser.parse()).rejects.toThrow(/invalid\.md/); + }); + + it('should handle empty files array', async () => { + const parser = new DocsParser(tempDir, '1.0.0', [], [], 'single'); + const result = await parser.parse(); + + expect(result).toEqual([]); + }); + }); + + describe('URL generation', () => { + it('should generate correct website and repo URLs', async () => { + const moduleContent = `# testModule + +_Main process_ + +Test module. + +## Methods + +### \`testModule.test()\` + +Test method. +`; + + const modulePath = path.join(tempDir, 'docs', 'api', 'test-module.md'); + await fs.promises.writeFile(modulePath, moduleContent); + + const parser = new DocsParser(tempDir, '2.0.0', [modulePath], [], 'single'); + const result = await parser.parse(); + + expect(result.length).toBeGreaterThan(0); + const testModule = result[0]; + expect(testModule).toBeDefined(); + expect(testModule.websiteUrl).toContain('/docs/api/test-module'); + expect(testModule.repoUrl).toContain('v2.0.0/docs/api/test-module.md'); + expect(testModule.version).toBe('2.0.0'); + }); + }); + + describe('Draft documentation', () => { + it('should skip draft documentation', async () => { + const draftContent = `# DraftAPI (Draft) + +This is draft documentation. +`; + + const draftPath = path.join(tempDir, 'docs', 'api', 'draft.md'); + await fs.promises.writeFile(draftPath, draftContent); + + const parser = new DocsParser(tempDir, '1.0.0', [draftPath], [], 'single'); + const result = await parser.parse(); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/tests/block-parsers.spec.ts b/tests/block-parsers.spec.ts index f1572ce..0276f10 100644 --- a/tests/block-parsers.spec.ts +++ b/tests/block-parsers.spec.ts @@ -1,9 +1,16 @@ import MarkdownIt from 'markdown-it'; import { describe, expect, it } from 'vitest'; -import { parseMethodBlocks } from '../src/block-parsers.js'; +import { + parseMethodBlocks, + parsePropertyBlocks, + parseEventBlocks, + guessParametersFromSignature, +} from '../src/block-parsers.js'; +import { DocumentationTag } from '../src/ParsedDocumentation.js'; describe('block parsers', () => { + describe('parseMethodBlocks', () => { it('should parse a method', async () => { const md = new MarkdownIt({ html: true }); const contents = ` @@ -116,4 +123,331 @@ describe('block parsers', () => { }, ]); }); + + it('should parse a method with return type', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`test.getValue()\` + +Returns \`string\` - The current value. +`; + + const allTokens = md.parse(contents, {}); + const methods = parseMethodBlocks(allTokens); + + expect(methods).toHaveLength(1); + expect(methods[0].returns).toBeDefined(); + expect(methods[0].returns?.type).toBe('String'); + }); + + it('should handle methods with no parameters', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`test.noParams()\` + +Does something without parameters. +`; + + const allTokens = md.parse(contents, {}); + const methods = parseMethodBlocks(allTokens); + + expect(methods).toHaveLength(1); + expect(methods[0].parameters).toHaveLength(0); + expect(methods[0].signature).toBe('()'); + }); + + it('should parse methods with generic types', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`test.getItems()\` + +Returns \`T[]\` - Array of items. +`; + + const allTokens = md.parse(contents, {}); + const methods = parseMethodBlocks(allTokens); + + expect(methods).toHaveLength(1); + expect(methods[0].rawGenerics).toBe(''); + }); + + it('should handle platform tags', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`test.macOnly()\` _macOS_ + +macOS specific method. +`; + + const allTokens = md.parse(contents, {}); + const methods = parseMethodBlocks(allTokens); + + expect(methods).toHaveLength(1); + expect(methods[0].additionalTags).toContain(DocumentationTag.OS_MACOS); + }); + + it('should return empty array for null tokens', () => { + expect(parseMethodBlocks(null)).toEqual([]); + }); +}); + +describe('parsePropertyBlocks', () => { + it('should parse a basic property', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`obj.name\` + +A \`string\` representing the name. +`; + + const allTokens = md.parse(contents, {}); + const properties = parsePropertyBlocks(allTokens); + + expect(properties).toHaveLength(1); + expect(properties[0].name).toBe('name'); + expect(properties[0].type).toBe('String'); + expect(properties[0].required).toBe(true); + }); + + it('should parse an optional property', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`obj.title\` + +A \`string\` (optional) representing the title. +`; + + const allTokens = md.parse(contents, {}); + const properties = parsePropertyBlocks(allTokens); + + expect(properties).toHaveLength(1); + expect(properties[0].name).toBe('title'); + expect(properties[0].required).toBe(false); + }); + + it('should parse a readonly property', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`obj.id\` _Readonly_ + +An \`Integer\` representing the unique identifier. +`; + + const allTokens = md.parse(contents, {}); + const properties = parsePropertyBlocks(allTokens); + + expect(properties).toHaveLength(1); + expect(properties[0].name).toBe('id'); + expect(properties[0].additionalTags).toContain(DocumentationTag.AVAILABILITY_READONLY); + }); + + it('should parse properties with complex types', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`obj.bounds\` + +A \`Rectangle | null\` representing the window bounds. +`; + + const allTokens = md.parse(contents, {}); + const properties = parsePropertyBlocks(allTokens); + + expect(properties).toHaveLength(1); + expect(properties[0].name).toBe('bounds'); + }); + + it('should parse multiple properties', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`obj.width\` + +A \`Integer\` for the width. + +# \`obj.height\` + +A \`Integer\` for the height. +`; + + const allTokens = md.parse(contents, {}); + const properties = parsePropertyBlocks(allTokens); + + expect(properties).toHaveLength(2); + expect(properties[0].name).toBe('width'); + expect(properties[1].name).toBe('height'); + }); + + it('should handle platform-specific properties', async () => { + const md = new MarkdownIt(); + const contents = ` +# \`obj.macProperty\` _macOS_ + +A \`boolean\` available only on macOS. +`; + + const allTokens = md.parse(contents, {}); + const properties = parsePropertyBlocks(allTokens); + + expect(properties).toHaveLength(1); + expect(properties[0].additionalTags).toContain(DocumentationTag.OS_MACOS); + }); + + it('should return empty array for null tokens', () => { + expect(parsePropertyBlocks(null)).toEqual([]); + }); +}); + +describe('parseEventBlocks', () => { + it('should parse a basic event', async () => { + const md = new MarkdownIt(); + const contents = ` +# Event: 'ready' + +Emitted when the app is ready. +`; + + const allTokens = md.parse(contents, {}); + const events = parseEventBlocks(allTokens); + + expect(events).toHaveLength(1); + expect(events[0].name).toBe('ready'); + expect(events[0].description).toContain('Emitted when the app is ready'); + expect(events[0].parameters).toHaveLength(0); + }); + + it('should parse an event with parameters', async () => { + const md = new MarkdownIt(); + const contents = ` +# Event: 'login' + +Returns: + +* \`event\` Event +* \`webContents\` WebContents +* \`authenticationResponseDetails\` Object + * \`url\` string + +Emitted when login is requested. +`; + + const allTokens = md.parse(contents, {}); + const events = parseEventBlocks(allTokens); + + expect(events).toHaveLength(1); + expect(events[0].name).toBe('login'); + expect(events[0].parameters).toHaveLength(3); + expect(events[0].parameters[0].name).toBe('event'); + expect(events[0].parameters[1].name).toBe('webContents'); + expect(events[0].parameters[2].name).toBe('authenticationResponseDetails'); + }); + + it('should parse multiple events', async () => { + const md = new MarkdownIt(); + const contents = ` +# Event: 'focus' + +Emitted when focused. + +# Event: 'blur' + +Emitted when blurred. +`; + + const allTokens = md.parse(contents, {}); + const events = parseEventBlocks(allTokens); + + expect(events).toHaveLength(2); + expect(events[0].name).toBe('focus'); + expect(events[1].name).toBe('blur'); + }); + + it('should handle platform-specific events', async () => { + const md = new MarkdownIt(); + const contents = ` +# Event: 'swipe' _macOS_ + +Returns: + +* \`event\` Event +* \`direction\` string + +Emitted on swipe gesture. +`; + + const allTokens = md.parse(contents, {}); + const events = parseEventBlocks(allTokens); + + expect(events).toHaveLength(1); + expect(events[0].additionalTags).toContain(DocumentationTag.OS_MACOS); + }); + + it('should parse event with deprecated tag', async () => { + const md = new MarkdownIt(); + const contents = ` +# Event: 'old-event' _Deprecated_ + +This event is deprecated. +`; + + const allTokens = md.parse(contents, {}); + const events = parseEventBlocks(allTokens); + + expect(events).toHaveLength(1); + expect(events[0].additionalTags).toContain(DocumentationTag.STABILITY_DEPRECATED); + }); + + it('should return empty array for null tokens', () => { + expect(parseEventBlocks(null)).toEqual([]); + }); +}); + +describe('guessParametersFromSignature', () => { + it('should parse single parameter', () => { + const params = guessParametersFromSignature('(x)'); + expect(params).toEqual([{ name: 'x', optional: false }]); + }); + + it('should parse multiple parameters', () => { + const params = guessParametersFromSignature('(x, y, z)'); + expect(params).toEqual([ + { name: 'x', optional: false }, + { name: 'y', optional: false }, + { name: 'z', optional: false }, + ]); + }); + + it('should parse optional parameters', () => { + const params = guessParametersFromSignature('(x, [y])'); + expect(params).toEqual([ + { name: 'x', optional: false }, + { name: 'y', optional: true }, + ]); + }); + + it('should parse nested optional parameters', () => { + const params = guessParametersFromSignature('(x, [y, [z]])'); + expect(params).toEqual([ + { name: 'x', optional: false }, + { name: 'y', optional: true }, + { name: 'z', optional: true }, + ]); + }); + + it('should handle spread parameters', () => { + const params = guessParametersFromSignature('(...args)'); + expect(params).toEqual([{ name: '...args', optional: false }]); + }); + + it('should reject empty parameters due to regex restriction', () => { + // The function's regex doesn't support empty parentheses by design + expect(() => guessParametersFromSignature('()')).toThrow(/signature should be a bracket wrapped group/); + }); + + it('should handle parameters with numbers', () => { + const params = guessParametersFromSignature('(x1, y2)'); + expect(params).toEqual([ + { name: 'x1', optional: false }, + { name: 'y2', optional: false }, + ]); + }); +}); }); diff --git a/tests/helpers.spec.ts b/tests/helpers.spec.ts index e330e31..3304947 100644 --- a/tests/helpers.spec.ts +++ b/tests/helpers.spec.ts @@ -13,4 +13,42 @@ describe('extendError', () => { const newError = extendError('foo', new Error('test')); expect(newError.stack!.split('\n')[0]).toEqual(chalk.red('foo')); }); + + it('should handle errors without a stack trace', () => { + const errorWithoutStack = new Error('test'); + delete (errorWithoutStack as any).stack; + const newError = extendError('foo', errorWithoutStack); + expect(newError.message).toEqual('foo - test'); + expect(newError.stack).toBeUndefined(); + }); + + it('should handle errors with non-string stack', () => { + const errorWithNonStringStack: any = new Error('test'); + errorWithNonStringStack.stack = null; + const newError = extendError('foo', errorWithNonStringStack); + expect(newError.message).toEqual('foo - test'); + expect(newError.stack).toBeNull(); + }); + + it('should handle plain objects with message and stack properties', () => { + const errorLikeObject = { message: 'custom error', stack: 'custom stack trace' }; + const newError = extendError('prefix', errorLikeObject); + expect(newError.message).toEqual('prefix - custom error'); + expect(newError.stack).toContain(chalk.red('prefix')); + expect(newError.stack).toContain('custom stack trace'); + }); + + it('should handle error-like objects without a message property', () => { + const errorWithoutMessage: any = { stack: 'some stack' }; + const newError = extendError('prefix', errorWithoutMessage); + expect(newError.message).toEqual('prefix - undefined'); + }); + + it('should preserve original stack when it is a string', () => { + const originalError = new Error('original'); + const originalStack = originalError.stack; + const newError = extendError('wrapped', originalError); + expect(newError.stack).toContain(chalk.red('wrapped')); + expect(newError.stack).toContain(originalStack!.split('\n').slice(1).join('\n')); + }); }); diff --git a/tests/index.spec.ts b/tests/index.spec.ts new file mode 100644 index 0000000..26a0bd6 --- /dev/null +++ b/tests/index.spec.ts @@ -0,0 +1,392 @@ +import { afterEach, assert, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { parseDocs } from '../src/index.js'; +import type { + ModuleDocumentationContainer, + ClassDocumentationContainer, + StructureDocumentationContainer, + ElementDocumentationContainer, +} from '../src/ParsedDocumentation.js'; + +type ParsedItem = + | ModuleDocumentationContainer + | ClassDocumentationContainer + | StructureDocumentationContainer + | ElementDocumentationContainer; + +describe('index (public API)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(process.cwd(), 'tests', 'temp-api-fixtures'); + await fs.promises.mkdir(tempDir, { recursive: true }); + await fs.promises.mkdir(path.join(tempDir, 'docs', 'api', 'structures'), { + recursive: true, + }); + }); + + afterEach(async () => { + if (fs.existsSync(tempDir)) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + }); + + describe('parseDocs', () => { + it('should parse documentation from a base directory', async () => { + const appContent = `# app + +_Main process_ + +Application module. + +## Methods + +### \`app.quit()\` + +Quit the application. +`; + + const appPath = path.join(tempDir, 'docs', 'api', 'app.md'); + await fs.promises.writeFile(appPath, appContent); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const appModule = result.find((m: ParsedItem) => m.name === 'app'); + expect(appModule).toBeDefined(); + }); + + it('should use single package mode by default', async () => { + const moduleWithClassContent = `# TestModule + +Module description. + +## Class: TestClass + +### Instance Methods + +#### \`test.method()\` + +Test method. +`; + + const modulePath = path.join(tempDir, 'docs', 'api', 'test-module.md'); + await fs.promises.writeFile(modulePath, moduleWithClassContent); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + // In single package mode, classes are not nested in modules + const testClass = result.find((item: ParsedItem) => item.name === 'TestClass'); + expect(testClass).toBeDefined(); + expect(testClass?.type).toBe('Class'); + }); + + it('should use multi package mode when specified', async () => { + const moduleWithClassContent = `# TestModule + +_Main process_ + +Module description. + +## Methods + +### \`TestModule.init()\` + +Initialize the module. + +## Class: TestClass + +### Instance Methods + +#### \`test.method()\` + +Test method. +`; + + const modulePath = path.join(tempDir, 'docs', 'api', 'test-module.md'); + await fs.promises.writeFile(modulePath, moduleWithClassContent); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + packageMode: 'multi', + }); + + // In multi package mode, classes are nested in modules + const testModule = result.find((item: ParsedItem) => item.name === 'TestModule'); + assert(testModule?.type === 'Module', 'Parsed module should be of type Module'); + + expect(testModule.exportedClasses).toHaveLength(1); + expect(testModule.exportedClasses[0].name).toBe('TestClass'); + }); + + it('should parse structures from structures directory', async () => { + const structureContent = `# Point Object + +* \`x\` Integer - X coordinate. +* \`y\` Integer - Y coordinate. +`; + + const structurePath = path.join(tempDir, 'docs', 'api', 'structures', 'point.md'); + await fs.promises.writeFile(structurePath, structureContent); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + const pointStructure = result.find((s: ParsedItem) => s.name === 'Point'); + expect(pointStructure).toBeDefined(); + expect(pointStructure?.type).toBe('Structure'); + }); + + it('should parse both API files and structures', async () => { + const appContent = `# app + +_Main process_ + +Application module. + +## Methods + +### \`app.quit()\` + +Quit the app. +`; + const structureContent = `# Options Object + +* \`width\` Integer +`; + + const appPath = path.join(tempDir, 'docs', 'api', 'app.md'); + const structurePath = path.join(tempDir, 'docs', 'api', 'structures', 'options.md'); + + await fs.promises.writeFile(appPath, appContent); + await fs.promises.writeFile(structurePath, structureContent); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + expect(result.some((item: ParsedItem) => item.name === 'app')).toBe(true); + expect(result.some((item: ParsedItem) => item.name === 'Options')).toBe(true); + }); + + it('should use README when useReadme is true', async () => { + const readmeContent = `# MyPackage + +Package documentation. + +## Methods + +### \`myPackage.init()\` + +Initialize the package. +`; + + const readmePath = path.join(tempDir, 'README.md'); + await fs.promises.writeFile(readmePath, readmeContent); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: true, + moduleVersion: '1.0.0', + }); + + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + + const packageModule = result.find((m: ParsedItem) => m.name === 'MyPackage'); + expect(packageModule).toBeDefined(); + }); + + it('should throw error when README not found with useReadme', async () => { + await expect( + parseDocs({ + baseDirectory: tempDir, + useReadme: true, + moduleVersion: '1.0.0', + }), + ).rejects.toThrow('README.md file not found'); + }); + + it('should handle empty API directory', async () => { + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '2.5.0', + }); + + expect(result).toEqual([]); + }); + + it('should handle directory with only structures', async () => { + const structureContent = `# Config Object + +* \`name\` string +`; + + const structurePath = path.join(tempDir, 'docs', 'api', 'structures', 'config.md'); + await fs.promises.writeFile(structurePath, structureContent); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('Structure'); + expect(result[0].name).toBe('Config'); + }); + + it('should include version in parsed documentation', async () => { + const appContent = `# app + +_Main process_ + +Application module. + +## Methods + +### \`app.quit()\` + +Quit the app. +`; + + const appPath = path.join(tempDir, 'docs', 'api', 'app.md'); + await fs.promises.writeFile(appPath, appContent); + + const version = '5.0.0-beta.1'; + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: version, + }); + + expect(result[0].version).toBe(version); + }); + + it('should handle multiple API files', async () => { + const files = [ + { + name: 'app.md', + content: + '# app\n\n_Main process_\n\nApp module.\n\n## Methods\n\n### `app.quit()`\n\nQuit.', + }, + { + name: 'browser-window.md', + content: + '# BrowserWindow\n\n_Main process_\n\n## Methods\n\n### `BrowserWindow.getAllWindows()`\n\nGet all windows.\n\n## Class: BrowserWindow\n\n### Instance Methods\n\n#### `win.close()`\n\nClose.', + }, + { + name: 'dialog.md', + content: + '# dialog\n\n_Main process_\n\nDialog module.\n\n## Methods\n\n### `dialog.showOpenDialog()`\n\nShow dialog.', + }, + ]; + + for (const file of files) { + const filePath = path.join(tempDir, 'docs', 'api', file.name); + await fs.promises.writeFile(filePath, file.content); + } + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + expect(result.length).toBeGreaterThanOrEqual(3); + expect(result.some((item: ParsedItem) => item.name === 'app')).toBe(true); + expect(result.some((item: ParsedItem) => item.name === 'BrowserWindow')).toBe(true); + expect(result.some((item: ParsedItem) => item.name === 'dialog')).toBe(true); + }); + }); + + describe('getAllMarkdownFiles (implicit testing)', () => { + it('should find all markdown files in API directory', async () => { + const files = ['app.md', 'dialog.md', 'menu.md']; + + for (const file of files) { + const filePath = path.join(tempDir, 'docs', 'api', file); + const moduleName = file.replace('.md', ''); + await fs.promises.writeFile( + filePath, + `# ${moduleName}\n\n_Main process_\n\nModule.\n\n## Methods\n\n### \`${moduleName}.test()\`\n\nTest.`, + ); + } + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + expect(result.length).toBe(files.length); + }); + + it('should not find non-markdown files', async () => { + await fs.promises.writeFile( + path.join(tempDir, 'docs', 'api', 'app.md'), + '# app\n\n_Main process_\n\nModule.\n\n## Methods\n\n### `app.test()`\n\nTest.', + ); + await fs.promises.writeFile( + path.join(tempDir, 'docs', 'api', 'README.txt'), + 'Not a markdown file', + ); + await fs.promises.writeFile( + path.join(tempDir, 'docs', 'api', 'config.json'), + '{"test": true}', + ); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + expect(result.length).toBe(1); + expect(result[0].name).toBe('app'); + }); + + it('should handle structures subdirectory separately', async () => { + await fs.promises.writeFile( + path.join(tempDir, 'docs', 'api', 'app.md'), + '# app\n\n_Main process_\n\nModule.\n\n## Methods\n\n### `app.test()`\n\nTest.', + ); + await fs.promises.writeFile( + path.join(tempDir, 'docs', 'api', 'structures', 'point.md'), + '# Point Object\n\n* `x` Integer', + ); + + const result = await parseDocs({ + baseDirectory: tempDir, + useReadme: false, + moduleVersion: '1.0.0', + }); + + // Should have parsed both api files and structures + expect(result.length).toBeGreaterThanOrEqual(1); + expect( + result.some((item: ParsedItem) => item.type === 'Module' || item.type === 'Structure'), + ).toBe(true); + }); + }); +}); diff --git a/tests/markdown-helpers.spec.ts b/tests/markdown-helpers.spec.ts index f64d733..9b0fa84 100644 --- a/tests/markdown-helpers.spec.ts +++ b/tests/markdown-helpers.spec.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import MarkdownIt from 'markdown-it'; -import { describe, expect, it } from 'vitest'; +import { assert, describe, expect, it } from 'vitest'; import { safelyJoinTokens, @@ -15,8 +15,26 @@ import { consumeTypedKeysList, findProcess, slugifyHeading, + findContentAfterList, + findContentAfterHeadingClose, + headingsAndContent, + findConstructorHeader, + getContentBeforeConstructor, + getContentBeforeFirstHeadingMatching, + findContentInsideHeader, + safelySeparateTypeStringOn, + getTopLevelMultiTypes, + getTopLevelOrderedTypes, + convertListToTypedKeys, } from '../src/markdown-helpers.js'; -import { DocumentationTag } from '../src/ParsedDocumentation.js'; +import { + DocumentationTag, + type DetailedFunctionType, + type DetailedObjectType, + type DetailedStringType, + type DetailedEventType, + type DetailedEventReferenceType, +} from '../src/ParsedDocumentation.js'; const getTokens = (md: string) => { const markdown = new MarkdownIt({ html: true }); @@ -748,4 +766,703 @@ foo`), expect(slugifyHeading(heading)).toBe(slugified); }); }); + + describe('findContentAfterList', () => { + it('should return content after a bullet list', () => { + const md = ` +* Item 1 +* Item 2 + +Content after list. +`; + const tokens = getTokens(md); + const content = findContentAfterList(tokens); + const joined = safelyJoinTokens(content); + expect(joined).toContain('Content after list'); + }); + + it('should return empty array when no list found and returnAllOnNoList is false', () => { + const md = `Just some text without a list.`; + const tokens = getTokens(md); + const content = findContentAfterList(tokens, false); + expect(content).toEqual([]); + }); + + it('should return all content after heading when no list found and returnAllOnNoList is true', () => { + const md = `# Heading + +Just some text without a list.`; + const tokens = getTokens(md); + const content = findContentAfterList(tokens, true); + expect(content.length).toBeGreaterThan(0); + }); + + it('should handle nested lists correctly', () => { + const md = ` +* Item 1 + * Nested item +* Item 2 + +After nested list. +`; + const tokens = getTokens(md); + const content = findContentAfterList(tokens); + const joined = safelyJoinTokens(content); + expect(joined).toContain('After nested list'); + }); + }); + + describe('findContentAfterHeadingClose', () => { + it('should return content after a heading', () => { + const md = `# Heading + +Content after heading.`; + const tokens = getTokens(md); + const content = findContentAfterHeadingClose(tokens); + const joined = safelyJoinTokens(content); + expect(joined).toContain('Content after heading'); + }); + + it('should return content until next heading', () => { + const md = `# Heading One + +Content for heading one. + +## Heading Two + +Content for heading two.`; + const tokens = getTokens(md); + const content = findContentAfterHeadingClose(tokens); + // The function returns tokens, check that we got some + expect(content.length).toBeGreaterThan(0); + }); + }); + + describe('headingsAndContent', () => { + it('should group headings with their content', () => { + const md = `# Heading 1 + +Content 1 + +## Heading 2 + +Content 2`; + const tokens = getTokens(md); + const groups = headingsAndContent(tokens); + + expect(groups).toHaveLength(2); + expect(groups[0].heading).toBe('Heading 1'); + expect(groups[0].level).toBe(1); + expect(groups[1].heading).toBe('Heading 2'); + expect(groups[1].level).toBe(2); + }); + + it('should handle nested heading levels', () => { + const md = `# Level 1 + +## Level 2 + +### Level 3 + +Back to level 2 + +## Another Level 2`; + const tokens = getTokens(md); + const groups = headingsAndContent(tokens); + + expect(groups.length).toBeGreaterThan(3); + expect(groups.some((g) => g.level === 1)).toBe(true); + expect(groups.some((g) => g.level === 2)).toBe(true); + expect(groups.some((g) => g.level === 3)).toBe(true); + }); + + it('should include content tokens for each heading', () => { + const md = `# Heading + +Some paragraph text.`; + const tokens = getTokens(md); + const groups = headingsAndContent(tokens); + + expect(groups[0].content.length).toBeGreaterThan(0); + }); + }); + + describe('findConstructorHeader', () => { + it('should find a constructor header', () => { + const md = `# Class + +### \`new BrowserWindow([options])\` + +* \`options\` Object (optional)`; + const tokens = getTokens(md); + const constructor = findConstructorHeader(tokens); + + expect(constructor).not.toBeNull(); + expect(constructor?.heading).toContain('new BrowserWindow'); + }); + + it('should return null when no constructor exists', () => { + const md = `# Class + +### \`someMethod()\` + +Regular method.`; + const tokens = getTokens(md); + const constructor = findConstructorHeader(tokens); + + expect(constructor).toBeNull(); + }); + + it('should only match level 3 headings', () => { + const md = `# Class + +## \`new BrowserWindow([options])\` + +Not a level 3 constructor. + +### \`new BrowserWindow([options])\` + +This is the right level.`; + const tokens = getTokens(md); + const constructor = findConstructorHeader(tokens); + + expect(constructor).not.toBeNull(); + expect(constructor?.level).toBe(3); + }); + }); + + describe('getContentBeforeConstructor', () => { + it('should return content before constructor', () => { + const md = `# Class: BrowserWindow + +Description of the class. + +### \`new BrowserWindow([options])\` + +Constructor details.`; + const tokens = getTokens(md); + const groups = getContentBeforeConstructor(tokens); + + expect(groups.length).toBeGreaterThan(0); + const firstGroup = groups[0]; + // Use findContentAfterHeadingClose to get the actual content without heading tokens + const content = safelyJoinTokens(findContentAfterHeadingClose(firstGroup.content)); + expect(content).toContain('Description'); + }); + + it('should return empty array when no constructor', () => { + const md = `# Class + +Just some content.`; + const tokens = getTokens(md); + const groups = getContentBeforeConstructor(tokens); + + expect(groups).toEqual([]); + }); + }); + + describe('getContentBeforeFirstHeadingMatching', () => { + it('should return content before matching heading', () => { + const md = `# Main + +Description here. + +## Methods + +Method content.`; + const tokens = getTokens(md); + const groups = getContentBeforeFirstHeadingMatching(tokens, (h) => h === 'Methods'); + + expect(groups.length).toBeGreaterThan(0); + const content = safelyJoinTokens(findContentAfterHeadingClose(groups[0].content)); + expect(content).toContain('Description'); + }); + + it('should handle no matching heading', () => { + const md = `# Main + +Description here.`; + const tokens = getTokens(md); + const groups = getContentBeforeFirstHeadingMatching(tokens, (h) => h === 'Methods'); + + // When no matching heading, returns all groups + expect(groups.length).toBeGreaterThanOrEqual(0); + }); + + it('should work with complex matchers', () => { + const md = `# API + +## Events + +Event content. + +## Methods + +Method content.`; + const tokens = getTokens(md); + const groups = getContentBeforeFirstHeadingMatching( + tokens, + (h) => h === 'Events' || h === 'Methods', + ); + + expect(groups.length).toBe(1); + expect(groups[0].heading).toBe('API'); + }); + }); + + describe('findContentInsideHeader', () => { + it('should find content inside a specific header', () => { + const md = `# API + +## Methods + +Method 1 + +Method 2 + +## Properties + +Property content`; + const tokens = getTokens(md); + const content = findContentInsideHeader(tokens, 'Methods', 2); + + expect(content).not.toBeNull(); + // The returned content doesn't include the heading itself, so it can be joined directly + expect(content!.length).toBeGreaterThan(0); + }); + + it('should return null when header not found', () => { + const md = `# API + +## Methods + +Content`; + const tokens = getTokens(md); + const content = findContentInsideHeader(tokens, 'Properties', 2); + + expect(content).toBeNull(); + }); + + it('should match both header name and level', () => { + const md = `# Methods + +Top level methods. + +## Methods + +Second level methods.`; + const tokens = getTokens(md); + const content = findContentInsideHeader(tokens, 'Methods', 2); + + expect(content).not.toBeNull(); + expect(content!.length).toBeGreaterThan(0); + }); + }); + + describe('safelySeparateTypeStringOn', () => { + it('should separate simple types on pipe', () => { + const result = safelySeparateTypeStringOn('string | number', '|'); + expect(result).toEqual(['string', 'number']); + }); + + it('should handle generic types without splitting inner content', () => { + const result = safelySeparateTypeStringOn('Promise | boolean', '|'); + expect(result).toEqual(['Promise', 'boolean']); + }); + + it('should handle nested generics', () => { + const result = safelySeparateTypeStringOn( + 'Map> | Array', + '|', + ); + expect(result).toEqual(['Map>', 'Array']); + }); + + it('should separate on comma', () => { + const result = safelySeparateTypeStringOn('string, number, boolean', ','); + expect(result).toEqual(['string', 'number', 'boolean']); + }); + + it('should handle object braces', () => { + const result = safelySeparateTypeStringOn('{ a: string | number } | boolean', '|'); + expect(result).toEqual(['{ a: string | number }', 'boolean']); + }); + + it('should trim whitespace', () => { + const result = safelySeparateTypeStringOn(' string | number ', '|'); + expect(result).toEqual(['string', 'number']); + }); + }); + + describe('getTopLevelMultiTypes', () => { + it('should split union types', () => { + const result = getTopLevelMultiTypes('string | number | boolean'); + expect(result).toEqual(['string', 'number', 'boolean']); + }); + + it('should not split inner generic types', () => { + const result = getTopLevelMultiTypes('Promise | string'); + expect(result).toEqual(['Promise', 'string']); + }); + + it('should handle single type', () => { + const result = getTopLevelMultiTypes('string'); + expect(result).toEqual(['string']); + }); + }); + + describe('getTopLevelOrderedTypes', () => { + it('should split comma-separated types', () => { + const result = getTopLevelOrderedTypes('string, number, boolean'); + expect(result).toEqual(['string', 'number', 'boolean']); + }); + + it('should not split inner generic commas', () => { + const result = getTopLevelOrderedTypes('Map, Array'); + expect(result).toEqual(['Map', 'Array']); + }); + + it('should handle single type', () => { + const result = getTopLevelOrderedTypes('string'); + expect(result).toEqual(['string']); + }); + }); + + describe('convertListToTypedKeys', () => { + it('should convert a simple list to typed keys', () => { + const md = ` +* \`name\` string - The name. +* \`age\` number - The age. +`; + const tokens = getTokens(md); + const list = findNextList(tokens); + expect(list).not.toBeNull(); + + const typedKeys = convertListToTypedKeys(list!); + expect(typedKeys.consumed).toBe(false); + expect(typedKeys.keys.length).toBe(2); + expect(typedKeys.keys[0].key).toBe('name'); + expect(typedKeys.keys[1].key).toBe('age'); + }); + + it('should handle optional parameters', () => { + const md = ` +* \`width\` Integer (optional) - Window width. +* \`height\` Integer - Window height. +`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + expect(typedKeys.keys[0].required).toBe(false); + expect(typedKeys.keys[1].required).toBe(true); + }); + + it('should handle nested properties', () => { + const md = ` +* \`options\` Object + * \`width\` Integer + * \`height\` Integer +`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + expect(typedKeys.keys).toHaveLength(1); + expect(typedKeys.keys[0].key).toBe('options'); + // The nested structure is complex - just verify we got the top-level key + }); + + it('should handle list items with invalid structure but nested list', () => { + const md = ` +* Some description without proper format + * \`validKey\` String - Valid nested key + * \`anotherKey\` Number - Another valid key +`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + expect(typedKeys.keys).toHaveLength(2); + expect(typedKeys.keys[0].key).toBe('validKey'); + expect(typedKeys.keys[1].key).toBe('anotherKey'); + }); + }); + + describe('extractStringEnum', () => { + it('should handle unexpected token at start', () => { + const result = extractStringEnum('values includes @invalid'); + expect(result).toBeNull(); + }); + + it('should throw on unexpected separator', () => { + expect(() => extractStringEnum('values includes "foo" @ "bar"')).toThrow( + /Unexpected separator token/, + ); + }); + + it('should throw on unexpected token after quote close', () => { + expect(() => extractStringEnum('values includes "foo"!')).toThrow( + /Unexpected separator token/, + ); + }); + + it('should throw on unclosed quote', () => { + expect(() => extractStringEnum('values includes "foo')).toThrow( + /Unexpected early termination/, + ); + }); + + it('should return null if no values found', () => { + const result = extractStringEnum('can be '); + expect(result).toBeNull(); + }); + + it('should handle strikethrough wrapped deprecated values', () => { + const result = extractStringEnum('values includes "foo", ~"bar"~'); + expect(result).toHaveLength(2); + expect(result![0].value).toBe('foo'); + expect(result![1].value).toBe('bar'); + }); + + it('should throw on mismatched wrapper unwrapping', () => { + expect(() => extractStringEnum('values includes ~"foo"!')).toThrow( + /Expected an unwrapping token that matched/, + ); + }); + + it('should handle terminating with period', () => { + const result = extractStringEnum('can be "foo".'); + expect(result).toHaveLength(1); + expect(result![0].value).toBe('foo'); + }); + + it('should handle terminating with semicolon', () => { + const result = extractStringEnum('can be "foo";'); + expect(result).toHaveLength(1); + }); + + it('should handle terminating with hyphen', () => { + const result = extractStringEnum('can be "foo" -'); + expect(result).toHaveLength(1); + }); + + it('should handle or an Object terminator', () => { + const result = extractStringEnum('can be "foo", or an Object'); + expect(result).toHaveLength(1); + expect(result![0].value).toBe('foo'); + }); + + it('should handle , or an Object terminator', () => { + const result = extractStringEnum('can be "foo", or an Object'); + expect(result).toHaveLength(1); + }); + + it('should handle suffixes to ignore like (Deprecated)', () => { + const result = extractStringEnum('can be "foo" (Deprecated), "bar"'); + expect(result).toHaveLength(2); + expect(result![0].value).toBe('foo'); + expect(result![1].value).toBe('bar'); + }); + + it('should gracefully terminate after comma when encountering unquoted text', () => { + const result = extractStringEnum('can be "foo", then other stuff'); + expect(result).toHaveLength(1); + expect(result![0].value).toBe('foo'); + }); + }); + + describe('extractReturnType', () => { + it('should return null return type when no Returns pattern found', () => { + const tokens = getTokens('This is just a description without returns'); + const result = extractReturnType(tokens); + expect(result.parsedReturnType).toBeNull(); + expect(result.parsedDescription).toBe('This is just a description without returns'); + }); + + it('should return null return type when Returns keyword present but no backticks', () => { + const tokens = getTokens('Returns something without backticks'); + const result = extractReturnType(tokens); + expect(result.parsedReturnType).toBeNull(); + expect(result.parsedDescription).toBe('Returns something without backticks'); + }); + + it('should handle returns with continuous sentence', () => { + const tokens = getTokens('Returns `String` the value'); + const result = extractReturnType(tokens); + expect(result.parsedReturnType).not.toBeNull(); + expect(result.parsedReturnType!.type).toBe('String'); + expect(result.parsedDescription).toBe('the value'); + }); + + it('should throw error on incorrectly formatted type union', () => { + const tokens = getTokens('Returns `A` | `B`'); + expect(() => extractReturnType(tokens)).toThrow( + /Type unions must be fully enclosed in backticks/, + ); + }); + + it('should handle returns with failed list parsing', () => { + const tokens = getTokens('Returns `Object`\n\n* invalid list item'); + const result = extractReturnType(tokens); + expect(result.parsedReturnType).not.toBeNull(); + expect(result.parsedReturnType!.type).toBe('Object'); + }); + + it('should handle description starting with pipe character', () => { + const tokens = getTokens('Returns `String` | another thing'); + expect(() => extractReturnType(tokens)).toThrow( + /Type unions must be fully enclosed in backticks/, + ); + }); + }); + + describe('rawTypeToTypeInformation edge cases', () => { + it('should handle Function type without subTypedKeys', () => { + const result = rawTypeToTypeInformation('Function', '', null); + assert(result.type === 'Function' && 'parameters' in result, 'Expected Function type'); + expect(result.parameters).toEqual([]); + expect(result.returns).toBeNull(); + }); + + it('should handle Function type with subTypedKeys', () => { + const md = ` +* \`callback\` Function - The callback +* \`event\` Event - The event object +`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + const result = rawTypeToTypeInformation('Function', '', typedKeys); + assert(result.type === 'Function' && 'parameters' in result, 'Expected Function type'); + expect(result.parameters).toHaveLength(2); + expect(result.parameters[0].name).toBe('callback'); + expect(result.parameters[1].name).toBe('event'); + expect(result.returns).toBeNull(); + }); + + it('should handle Object type without subTypedKeys', () => { + const result = rawTypeToTypeInformation('Object', '', null); + assert(result.type === 'Object' && 'properties' in result, 'Expected Object type'); + expect(result.properties).toEqual([]); + }); + + it('should handle String type with subTypedKeys', () => { + const md = ` +* \`option1\` - First option +* \`option2\` - Second option +`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + const result = rawTypeToTypeInformation('String', '', typedKeys); + assert(result.type === 'String' && 'possibleValues' in result, 'Expected String type'); + expect(result.possibleValues).toHaveLength(2); + expect(result.possibleValues?.[0].value).toBe('option1'); + }); + + it('should handle Event<> with inner type', () => { + const result = rawTypeToTypeInformation('Event', '', null); + assert( + result.type === 'Event' && 'eventPropertiesReference' in result, + 'Expected Event type', + ); + expect(result.eventPropertiesReference).toBeDefined(); + expect(result.eventPropertiesReference.type).toBe('CustomEvent'); + }); + + it('should throw on Event<> with both inner type and parameter list', () => { + const md = `* \`foo\` String`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + expect(() => rawTypeToTypeInformation('Event', '', typedKeys)).toThrow( + /Event<> should not have declared inner types AND a parameter list/, + ); + }); + + it('should throw on Event<> with multiple inner types', () => { + expect(() => rawTypeToTypeInformation('Event', '', null)).toThrow( + /Event<> should have at most one inner type/, + ); + }); + + it('should throw on Event<> without inner type or parameter list', () => { + expect(() => rawTypeToTypeInformation('Event<>', '', null)).toThrow( + /Event<> declaration without a parameter list/, + ); + }); + + it('should handle Event<> with parameter list', () => { + const md = `* \`detail\` String - Event detail`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + const result = rawTypeToTypeInformation('Event<>', '', typedKeys); + assert(result.type === 'Event' && 'eventProperties' in result, 'Expected Event type'); + expect(result.eventProperties).toHaveLength(1); + expect(result.eventProperties[0].name).toBe('detail'); + }); + + it('should handle Function<> with generic types', () => { + const result = rawTypeToTypeInformation('Function', '', null); + assert(result.type === 'Function' && 'parameters' in result, 'Expected Function type'); + expect(result.parameters).toHaveLength(2); + expect(result.returns?.type).toBe('Boolean'); + }); + + it('should handle Function<> without generic params falling back to subTypedKeys', () => { + const md = `* \`arg1\` String - First arg`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + const result = rawTypeToTypeInformation('Function', '', typedKeys); + assert(result.type === 'Function' && 'parameters' in result, 'Expected Function type'); + expect(result.parameters).toHaveLength(1); + expect(result.parameters[0].name).toBe('arg1'); + expect(result.returns?.type).toBe('Boolean'); + }); + + it('should throw on generic type without inner types', () => { + expect(() => rawTypeToTypeInformation('GenericType<>', '', null)).toThrow( + /should have at least one inner type/, + ); + }); + + it('should handle generic types with Object inner type and subTypedKeys', () => { + const md = `* \`prop\` String - Property`; + const tokens = getTokens(md); + const list = findNextList(tokens); + const typedKeys = convertListToTypedKeys(list!); + + const result = rawTypeToTypeInformation('Promise', '', typedKeys); + expect(result.type).toBe('Promise'); + expect(result.innerTypes).toHaveLength(1); + assert( + result.innerTypes?.[0].type === 'Object' && 'properties' in result.innerTypes[0], + 'Expected Object type', + ); + expect(result.innerTypes[0].properties).toHaveLength(1); + }); + }); + + describe('findContentAfterList', () => { + it('should return content starting from heading_close when returnAllOnNoList=true and no list found', () => { + const md = ` +### Heading + +Some content without a list + +#### Next Heading +`; + const tokens = getTokens(md); + const result = findContentAfterList(tokens, true); + expect(result.length).toBeGreaterThan(0); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 3fa5157..bffa2fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,58 @@ __metadata: version: 8 cacheKey: 10c0 +"@ampproject/remapping@npm:^2.3.0": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.25.4": + version: 7.28.5 + resolution: "@babel/parser@npm:7.28.5" + dependencies: + "@babel/types": "npm:^7.28.5" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/5bbe48bf2c79594ac02b490a41ffde7ef5aa22a9a88ad6bcc78432a6ba8a9d638d531d868bd1f104633f1f6bba9905746e15185b8276a3756c42b765d131b1ef + languageName: node + linkType: hard + +"@babel/types@npm:^7.25.4, @babel/types@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/types@npm:7.28.5" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/a5a483d2100befbf125793640dec26b90b95fd233a94c19573325898a5ce1e52cdfa96e495c7dcc31b5eca5b66ce3e6d4a0f5a4a62daec271455959f208ab08a + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@electron/docs-parser@workspace:.": version: 0.0.0-use.local resolution: "@electron/docs-parser@workspace:." @@ -15,6 +67,7 @@ __metadata: "@types/markdown-it": "npm:^14.1.2" "@types/node": "npm:^22.10.7" "@types/pretty-ms": "npm:^5.0.1" + "@vitest/coverage-v8": "npm:3.0.5" chai: "npm:^5.1.1" chalk: "npm:^5.3.0" husky: "npm:^9.1.6" @@ -230,6 +283,37 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + "@jridgewell/sourcemap-codec@npm:^1.5.0": version: 1.5.0 resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" @@ -237,6 +321,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + "@npmcli/agent@npm:^3.0.0": version: 3.0.0 resolution: "@npmcli/agent@npm:3.0.0" @@ -492,6 +586,32 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:3.0.5": + version: 3.0.5 + resolution: "@vitest/coverage-v8@npm:3.0.5" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@bcoe/v8-coverage": "npm:^1.0.2" + debug: "npm:^4.4.0" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-lib-source-maps: "npm:^5.0.6" + istanbul-reports: "npm:^3.1.7" + magic-string: "npm:^0.30.17" + magicast: "npm:^0.3.5" + std-env: "npm:^3.8.0" + test-exclude: "npm:^7.0.1" + tinyrainbow: "npm:^2.0.0" + peerDependencies: + "@vitest/browser": 3.0.5 + vitest: 3.0.5 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/2b1670bbe7bedbb7eaef28e0e4e6bebc38900934525ff28e7be23ee2f719bae10fd56afd586142a0e97ccb7ae3e098ad56136c990fecb745a9473b1851746ff7 + languageName: node + linkType: hard + "@vitest/expect@npm:3.0.5": version: 3.0.5 resolution: "@vitest/expect@npm:3.0.5" @@ -800,7 +920,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.1.1, debug@npm:^4.3.4": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -1140,7 +1260,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2": +"glob@npm:^10.2.2, glob@npm:^10.4.1": version: 10.5.0 resolution: "glob@npm:10.5.0" dependencies: @@ -1163,6 +1283,20 @@ __metadata: languageName: node linkType: hard +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 + languageName: node + linkType: hard + +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" @@ -1301,6 +1435,45 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^5.0.6": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.7": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc + languageName: node + linkType: hard + "jackspeak@npm:^3.1.2": version: 3.4.3 resolution: "jackspeak@npm:3.4.3" @@ -1426,6 +1599,26 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.3.5": + version: 0.3.5 + resolution: "magicast@npm:0.3.5" + dependencies: + "@babel/parser": "npm:^7.25.4" + "@babel/types": "npm:^7.25.4" + source-map-js: "npm:^1.2.0" + checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64 + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "make-fetch-happen@npm:^14.0.3": version: 14.0.3 resolution: "make-fetch-happen@npm:14.0.3" @@ -1953,7 +2146,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5": +"semver@npm:^7.3.5, semver@npm:^7.5.3": version: 7.7.3 resolution: "semver@npm:7.7.3" bin: @@ -2040,7 +2233,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.2.1": +"source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -2151,6 +2344,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 + languageName: node + linkType: hard + "tar@npm:^7.4.3": version: 7.5.2 resolution: "tar@npm:7.5.2" @@ -2164,6 +2366,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.1 + resolution: "test-exclude@npm:7.0.1" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^9.0.4" + checksum: 10c0/6d67b9af4336a2e12b26a68c83308c7863534c65f27ed4ff7068a56f5a58f7ac703e8fc80f698a19bb154fd8f705cdf7ec347d9512b2c522c737269507e7b263 + languageName: node + linkType: hard + "tinybench@npm:^2.9.0": version: 2.9.0 resolution: "tinybench@npm:2.9.0"