diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..7b22682 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,3 @@ +ignore: + # deprecated code + - 'src/test-utils/**' diff --git a/fixtures/test-utils/glob-test/a.ts b/fixtures/test-utils/glob-test/a.ts deleted file mode 100644 index 0bd75c8..0000000 --- a/fixtures/test-utils/glob-test/a.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -export class A {} diff --git a/fixtures/test-utils/glob-test/b.ts b/fixtures/test-utils/glob-test/b.ts deleted file mode 100644 index ef54604..0000000 --- a/fixtures/test-utils/glob-test/b.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -export class B {} diff --git a/fixtures/test-utils/glob-test/tsconfig.json b/fixtures/test-utils/glob-test/tsconfig.json deleted file mode 100644 index e6f83d5..0000000 --- a/fixtures/test-utils/glob-test/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["*.ts"] -} diff --git a/package.json b/package.json index 6a6c012..0621f52 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "components", "schema", "test-utils", + "test-utils-new", "*.js", "*.d.ts" ], diff --git a/src/index.ts b/src/index.ts index 32ce1ae..648780f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,4 @@ export * from './components/interfaces'; export { writeComponentsDocumentation } from './components'; export { documentTestUtils } from './test-utils'; +export { writeTestUtilsDocumentation } from './test-utils-new'; diff --git a/src/test-utils-new/extractor.ts b/src/test-utils-new/extractor.ts new file mode 100644 index 0000000..fe6668b --- /dev/null +++ b/src/test-utils-new/extractor.ts @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import ts from 'typescript'; +import { extractDeclaration, getDescription, isOptional, stringifyType } from '../components/type-utils'; +import { TestUtilsDoc } from '../test-utils/interfaces'; + +function getInheritedFrom(declaration: ts.Declaration, currentClassName: string) { + if (!ts.isMethodDeclaration(declaration) || !ts.isClassDeclaration(declaration.parent) || !declaration.parent.name) { + throw new Error(`Unexpected declaration parent: ${declaration.getText()}`); + } + const parentName = declaration.parent.name.getText(); + if (parentName === currentClassName) { + return undefined; + } + return { name: parentName + '.' + declaration.name.getText() }; +} + +function getDefaultValue(declaration: ts.Declaration) { + if (!ts.isParameter(declaration)) { + throw new Error(`Unexpected declaration: ${declaration.getText()}`); + } + if (!declaration.initializer) { + return undefined; + } + return declaration.initializer.getText(); +} + +export default function extractDocumentation(sourceFile: ts.SourceFile, checker: ts.TypeChecker): Array { + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) { + throw new Error(`Unable to resolve module: ${sourceFile.fileName}`); + } + + const exportSymbols = checker.getExportsOfModule(moduleSymbol); + const definitions: Array = []; + + for (const symbol of exportSymbols) { + if (!(symbol.flags & ts.SymbolFlags.Class)) { + throw new Error(`Exported symbol is not a class, got ${checker.symbolToString(symbol)}`); + } + const className = symbol.getName(); + const classType = checker.getTypeAtLocation(extractDeclaration(symbol)); + const classDefinition: TestUtilsDoc = { name: className, methods: [] }; + for (const property of classType.getProperties()) { + const declaration = extractDeclaration(property); + const modifiers = (ts.canHaveModifiers(declaration) && ts.getModifiers(declaration)) || []; + if ( + modifiers.find( + modifier => modifier.kind & ts.SyntaxKind.ProtectedKeyword || modifier.kind & ts.SyntaxKind.PrivateKeyword + ) + ) { + continue; + } + const type = checker.getTypeAtLocation(declaration); + if (type.getCallSignatures().length !== 1) { + throw new Error(`Unexpected member on ${className} – ${property.getName()}: ${stringifyType(type, checker)}`); + } + const returnType = type.getCallSignatures()[0].getReturnType(); + classDefinition.methods.push({ + name: property.getName(), + description: getDescription(property.getDocumentationComment(checker), declaration).text, + inheritedFrom: getInheritedFrom(declaration, className), + parameters: type.getCallSignatures()[0].parameters.map(parameter => { + const paramType = checker.getTypeAtLocation(extractDeclaration(parameter)); + return { + name: parameter.name, + typeName: stringifyType(paramType, checker), + description: getDescription(parameter.getDocumentationComment(checker), declaration).text, + flags: { isOptional: isOptional(paramType) }, + defaultValue: getDefaultValue(extractDeclaration(parameter)), + }; + }), + returnType: { name: stringifyType(returnType, checker) }, + }); + } + classDefinition.methods.sort((a, b) => a.name.localeCompare(b.name)); + + definitions.push(classDefinition); + } + + return definitions; +} diff --git a/src/test-utils-new/index.ts b/src/test-utils-new/index.ts new file mode 100644 index 0000000..aa80492 --- /dev/null +++ b/src/test-utils-new/index.ts @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import fs from 'node:fs'; +import pathe from 'pathe'; +import { bootstrapTypescriptProject } from '../bootstrap/typescript'; +import extractDocumentation from './extractor'; +import { TestUtilsDoc } from '../test-utils/interfaces'; + +export interface TestUtilsDocumenterOptions { + tsconfigPath: string; + domUtilsRoot: string; + selectorsUtilsRoot: string; +} + +interface TestUtilsDefinitions { + domDefinitions: Array; + selectorsDefinitions: Array; +} + +export function documentTestUtilsNew(options: TestUtilsDocumenterOptions): TestUtilsDefinitions { + const domUtilsRoot = pathe.resolve(options.domUtilsRoot); + const selectorsUtilsRoot = pathe.resolve(options.selectorsUtilsRoot); + const program = bootstrapTypescriptProject(options.tsconfigPath); + const checker = program.getTypeChecker(); + + const domUtilsFile = program.getSourceFiles().find(file => file.fileName === domUtilsRoot); + if (!domUtilsFile) { + throw new Error(`File '${domUtilsRoot}' not found`); + } + + const selectorsUtilsFile = program.getSourceFiles().find(file => file.fileName === selectorsUtilsRoot); + if (!selectorsUtilsFile) { + throw new Error(`File '${selectorsUtilsFile}' not found`); + } + return { + domDefinitions: extractDocumentation(domUtilsFile, checker), + selectorsDefinitions: extractDocumentation(selectorsUtilsFile, checker), + }; +} + +export function writeTestUtilsDocumentation({ + outDir, + ...rest +}: TestUtilsDocumenterOptions & { outDir: string }): void { + const { domDefinitions, selectorsDefinitions } = documentTestUtilsNew(rest); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + pathe.join(outDir, 'dom.js'), + `module.exports = { classes: ${JSON.stringify(domDefinitions, null, 2)} };` + ); + fs.writeFileSync( + pathe.join(outDir, 'selectors.js'), + `module.exports = { classes: ${JSON.stringify(selectorsDefinitions, null, 2)} };` + ); + fs.copyFileSync(require.resolve('./interfaces.d.ts'), pathe.join(outDir, 'interfaces.d.ts')); + const dtsTemplate = `import { TestUtilsDefinition } from './interfaces'; + declare const definitions: TestUtilsDefinition; + export = definitions; + `; + fs.writeFileSync(pathe.join(outDir, 'dom.d.ts'), dtsTemplate); + fs.writeFileSync(pathe.join(outDir, 'selectors.d.ts'), dtsTemplate); +} diff --git a/src/test-utils-new/interfaces.ts b/src/test-utils-new/interfaces.ts new file mode 100644 index 0000000..56c585d --- /dev/null +++ b/src/test-utils-new/interfaces.ts @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +export interface Parameter { + name: string; + typeName?: string; + description?: string; + flags: { isOptional?: boolean }; + defaultValue?: string; +} + +export interface TestUtilMethod { + name: string; + description?: string; + returnType?: { + name: string; + }; + parameters: Array; + inheritedFrom?: { + name: string; + }; +} + +export interface TestUtilsDoc { + name: string; + methods: Array; +} + +export interface TestUtilsDefinition { + classes: Array; +} diff --git a/test/test-utils/__snapshots__/doc-generation.test.ts.snap b/test/test-utils/__snapshots__/doc-generation.test.ts.snap index c4cb419..7ec4f12 100644 --- a/test/test-utils/__snapshots__/doc-generation.test.ts.snap +++ b/test/test-utils/__snapshots__/doc-generation.test.ts.snap @@ -4,9 +4,7 @@ exports[`Generate documentation > For simple cases 1`] = ` [ { "defaultValue": undefined, - "description": " - -", + "description": undefined, "flags": { "isOptional": false, }, @@ -25,7 +23,7 @@ exports[`Generate documentation > deal with more complex types 1`] = ` "isOptional": false, }, "name": "all", - "typeName": "Array", + "typeName": "Array", }, ] `; diff --git a/test/test-utils/doc-generation.test.ts b/test/test-utils/doc-generation.test.ts index f72fd11..177be9a 100644 --- a/test/test-utils/doc-generation.test.ts +++ b/test/test-utils/doc-generation.test.ts @@ -19,32 +19,32 @@ describe('Generate documentation', () => { const noOpMethod = methods.find(method => method.name === 'noOp'); expect(noOpMethod).toBeDefined(); - expect(noOpMethod?.returnType).toEqual({ name: 'void', type: 'intrinsic' }); + expect(noOpMethod?.returnType).toEqual({ name: 'void' }); expect(noOpMethod?.parameters).toEqual([]); expect(noOpMethod?.description).toBeUndefined(); expect(noOpMethod?.inheritedFrom).toBeUndefined(); const findStringMethod = methods.find(method => method.name === 'findString'); expect(findStringMethod).toBeDefined(); - expect(findStringMethod?.returnType).toEqual({ name: 'string', type: 'intrinsic' }); + expect(findStringMethod?.returnType).toEqual({ name: 'string' }); expect(findStringMethod?.parameters).toEqual([]); expect(findStringMethod?.description).toBe( - 'Finds a string.\nThe function may look trivial but people have been losing their words\nsince centuries.\n' + 'Finds a string.\n\nThe function may look trivial but people have been losing their words\nsince centuries.' ); expect(findStringMethod?.inheritedFrom).toBeUndefined(); const setStringMethod = methods.find(method => method.name === 'setString'); expect(setStringMethod).toBeDefined(); - expect(setStringMethod?.returnType).toEqual({ name: 'void', type: 'intrinsic' }); + expect(setStringMethod?.returnType).toEqual({ name: 'void' }); expect(setStringMethod?.parameters).toMatchSnapshot(); expect(setStringMethod?.description).toBe('Short Text'); expect(setStringMethod?.inheritedFrom).toBeUndefined(); const findObjectMethod = methods.find(method => method.name === 'findObject'); expect(findObjectMethod).toBeDefined(); - expect(findObjectMethod?.returnType).toEqual({ name: 'TestReturnType', type: 'reference' }); + expect(findObjectMethod?.returnType).toEqual({ name: 'TestReturnType' }); expect(findObjectMethod?.parameters).toEqual([]); - expect(findObjectMethod?.description).toBe('Short Text.\nLong Text.\n'); + expect(findObjectMethod?.description).toBe('Short Text.\n\nLong Text.'); expect(findObjectMethod?.inheritedFrom).toBeUndefined(); }); @@ -61,26 +61,14 @@ describe('Generate documentation', () => { const findAllMethod = methods.find(method => method.name === 'findAll'); expect(findAllMethod).toBeDefined(); - expect(findAllMethod?.returnType).toEqual({ - name: 'Array', - type: 'reference', - typeArguments: [ - { - type: 'reference', - name: 'HTMLElement', - }, - ], - }); + expect(findAllMethod?.returnType).toEqual({ name: 'Array' }); expect(findAllMethod?.parameters).toEqual([]); expect(findAllMethod?.description).toBeUndefined(); expect(findAllMethod?.inheritedFrom).toBeUndefined(); const setAllMethod = methods.find(method => method.name === 'setAll'); expect(setAllMethod).toBeDefined(); - expect(setAllMethod?.returnType).toEqual({ - name: 'void', - type: 'intrinsic', - }); + expect(setAllMethod?.returnType).toEqual({ name: 'void' }); expect(setAllMethod?.parameters).toMatchSnapshot(); expect(setAllMethod?.description).toBeUndefined(); expect(setAllMethod?.inheritedFrom).toBeUndefined(); @@ -89,7 +77,7 @@ describe('Generate documentation', () => { test('and deal with inheritance', () => { const results = buildTestUtilsProject('inheritance'); - expect(results.length).toBe(2); + expect(results.length).toBe(1); const classDoc = results.find(classDoc => classDoc.name === 'TestUtilWrapper'); expect(classDoc).toBeDefined(); @@ -121,11 +109,12 @@ describe('Generate documentation', () => { parameters: [ { name: 'order', + typeName: '"first" | "last"', flags: { isOptional: false }, - defaultValue: '"first"', + defaultValue: "'first'", }, ], - returnType: { name: 'void', type: 'intrinsic' }, + returnType: { name: 'void' }, }, { name: 'openDropdown', @@ -137,7 +126,7 @@ describe('Generate documentation', () => { defaultValue: 'false', }, ], - returnType: { name: 'void', type: 'intrinsic' }, + returnType: { name: 'void' }, }, { name: 'selectOption', @@ -149,7 +138,7 @@ describe('Generate documentation', () => { defaultValue: '1', }, ], - returnType: { name: 'void', type: 'intrinsic' }, + returnType: { name: 'void' }, }, ]); }); diff --git a/test/test-utils/test-helpers.ts b/test/test-utils/test-helpers.ts index d743201..5ab22e4 100644 --- a/test/test-utils/test-helpers.ts +++ b/test/test-utils/test-helpers.ts @@ -1,13 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { documentTestUtils } from '../../src/test-utils'; +import { documentTestUtilsNew, TestUtilsDocumenterOptions } from '../../src/test-utils-new'; import { TestUtilsDoc } from '../../src/test-utils/interfaces'; -export function buildTestUtilsProject(name: string, testGlob?: string): TestUtilsDoc[] { - return documentTestUtils( - { - tsconfig: require.resolve(`../../fixtures/test-utils/${name}/tsconfig.json`), - }, - testGlob || `fixtures/test-utils/${name}/**/*` - ); +export function buildTestUtilsProject( + name: string, + configOverrides?: Partial +): TestUtilsDoc[] { + return documentTestUtilsNew({ + tsconfigPath: require.resolve(`../../fixtures/test-utils/${name}/tsconfig.json`), + domUtilsRoot: `fixtures/test-utils/${name}/index.ts`, + selectorsUtilsRoot: `fixtures/test-utils/${name}/index.ts`, + ...configOverrides, + }).domDefinitions; } diff --git a/test/test-utils/usage.test.ts b/test/test-utils/usage.test.ts index ecb3d95..331a714 100644 --- a/test/test-utils/usage.test.ts +++ b/test/test-utils/usage.test.ts @@ -5,24 +5,22 @@ import { buildTestUtilsProject } from './test-helpers'; describe('documentTestUtils throws error for ', () => { test('failing project generation because of invalid config', () => { - expect(() => buildTestUtilsProject('errors-config')).toThrow('Errors during parsing configuration'); + expect(() => buildTestUtilsProject('errors-config')).toThrow('Failed to parse tsconfig.json'); }); test('failing project generation because of faulty project files', () => { - expect(() => buildTestUtilsProject('errors-types')).toThrow('Project generation failed'); + expect(() => buildTestUtilsProject('errors-types')).toThrow('Compilation failed'); }); test('having no input files because of the config', () => { - expect(() => buildTestUtilsProject('errors-empty')).toThrow('Errors during parsing configuration'); + expect(() => buildTestUtilsProject('errors-empty')).toThrow('Failed to parse tsconfig.json'); }); test('having no input files because of a non-matching glob', () => { - expect(() => buildTestUtilsProject('simple', 'thisGlobWontMatchAnything')).toThrow('No input files to convert'); + expect(() => + buildTestUtilsProject('simple', { + domUtilsRoot: 'fixtures/does-not-exist/index.ts', + }) + ).toThrow(/File '.*fixtures\/does-not-exist\/index.ts' not found/); }); }); - -test('glob works', () => { - const results = buildTestUtilsProject('glob-test', '**/a.ts'); - expect(results.length).toBe(1); - expect(results[0].name).toBe('A'); -}); diff --git a/test/test-utils/writer.test.ts b/test/test-utils/writer.test.ts new file mode 100644 index 0000000..e9ab46e --- /dev/null +++ b/test/test-utils/writer.test.ts @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { expect, test } from 'vitest'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import pathe from 'pathe'; +import { getTemporaryDir } from '../components/test-helpers'; +// must use compiled artifacts, because the code relies on generated files +import { writeTestUtilsDocumentation } from '../../lib'; + +test('should write documentation files into the outDir', async () => { + const outDir = getTemporaryDir(); + expect(fs.readdirSync(outDir)).toHaveLength(0); + + writeTestUtilsDocumentation({ + tsconfigPath: pathe.resolve('fixtures/test-utils/simple/tsconfig.json'), + domUtilsRoot: 'fixtures/test-utils/simple/index.ts', + selectorsUtilsRoot: 'fixtures/test-utils/simple/index.ts', + outDir, + }); + + expect(fs.readdirSync(outDir)).toEqual(['dom.d.ts', 'dom.js', 'interfaces.d.ts', 'selectors.d.ts', 'selectors.js']); + const { default: domDefinitions } = await import(pathe.join(outDir, 'dom.js')); + expect(domDefinitions).toEqual({ + classes: [expect.objectContaining({ name: 'TestUtilWrapper' })], + }); + const { default: selectorsDefinitions } = await import(pathe.join(outDir, 'selectors.js')); + expect(selectorsDefinitions).toEqual({ + classes: [expect.objectContaining({ name: 'TestUtilWrapper' })], + }); + expect(() => execSync('tsc dom.d.ts selectors.d.ts', { cwd: outDir, stdio: 'inherit' })).not.toThrow(); +});