diff --git a/packages/analytics/config/jest.config.js b/packages/analytics/config/jest.config.js index 8d63e5f025..fa007dbbf0 100644 --- a/packages/analytics/config/jest.config.js +++ b/packages/analytics/config/jest.config.js @@ -31,11 +31,15 @@ const config = { // An array of regexp pattern strings that are matched against all file paths before executing the test. // https://jestjs.io/docs/configuration#coveragepathignorepatterns-arraystring - coveragePathIgnorePatterns: ['__fixtures__'], + coveragePathIgnorePatterns: ['__fixtures__', 'bin'], // A list of reporter names that Jest uses when writing coverage reports. Any istanbul reporter can be used. // https://jestjs.io/docs/configuration#coveragereporters-arraystring--string-options coverageReporters: ['text', 'text-summary', ['lcov', { projectRoot: '../../' }]], + + // An array of regexp pattern strings that are matched against all source file paths before transformation. + // https://jestjs.io/docs/configuration#transformignorepatterns-arraystring + transformIgnorePatterns: ['/../../node_modules/zx'], }; export default config; diff --git a/packages/analytics/src/__tests__/runner.test.ts b/packages/analytics/src/__tests__/runner.test.ts new file mode 100644 index 0000000000..c6212d01b8 --- /dev/null +++ b/packages/analytics/src/__tests__/runner.test.ts @@ -0,0 +1,59 @@ +import scanner from 'react-scanner'; +import { path } from 'zx'; +import { runner } from '../runner'; +import { RunnerConfig } from '../types'; + +jest.mock('../scanners/twigScanner', () => { + return jest.fn(() => Promise.resolve({ test: 'test' })); +}); + +describe('runner', () => { + jest.spyOn(path, 'resolve').mockImplementation((...args: string[]) => args.join('/')); + + it('should return tracked data for react type', async () => { + const config = { + react: {}, + twig: {}, + } as RunnerConfig; + const source = '/path/to/source'; + const type = 'react'; + jest.spyOn(scanner, 'run').mockResolvedValue({ test: 'test' }); + + const trackedData = await runner(config, source, type); + + expect(trackedData.spiritVersion).toBeDefined(); + expect(trackedData.trackedData.react).toEqual({ test: 'test' }); + expect(trackedData.trackedData.twig).toEqual({}); + }); + + it('should return tracked data for twig type', async () => { + const config = { + react: {}, + twig: {}, + } as RunnerConfig; + const source = '/path/to/source'; + const type = 'twig'; + + const trackedData = await runner(config, source, type); + + expect(trackedData.spiritVersion).toBeDefined(); + expect(trackedData.trackedData.react).toEqual({}); + expect(trackedData.trackedData.twig).toEqual({ test: 'test' }); + }); + + it('should return tracked data for both react and twig types', async () => { + const config = { + react: {}, + twig: {}, + } as RunnerConfig; + const source = '/path/to/source'; + const type = null; + jest.spyOn(scanner, 'run').mockResolvedValue({ test: 'test' }); + + const trackedData = await runner(config, source, type); + + expect(trackedData.spiritVersion).toBeDefined(); + expect(trackedData.trackedData.react).toEqual({ test: 'test' }); + expect(trackedData.trackedData.twig).toEqual({ test: 'test' }); + }); +}); diff --git a/packages/analytics/src/runner.ts b/packages/analytics/src/runner.ts index 95827ebdd1..20e297ba8c 100644 --- a/packages/analytics/src/runner.ts +++ b/packages/analytics/src/runner.ts @@ -29,18 +29,18 @@ const getTrackedData = async ({ let twigOutput = {}; if (type === 'react' || type === null) { - reactOutput = await reactScanner({ ...config, crawlFrom }); + reactOutput = await reactScanner({ ...config.react, crawlFrom }); } if (type === 'twig' || type === null) { - twigOutput = await twigScanner({ ...config, crawlFrom }); + twigOutput = await twigScanner({ ...config.twig, crawlFrom }); } return { spiritVersion, trackedData: { - ...reactOutput, - ...twigOutput, + react: reactOutput, + twig: twigOutput, }, }; }; diff --git a/packages/analytics/src/scanners/__tests__/reactScanner.test.ts b/packages/analytics/src/scanners/__tests__/reactScanner.test.ts new file mode 100644 index 0000000000..b14d4476ec --- /dev/null +++ b/packages/analytics/src/scanners/__tests__/reactScanner.test.ts @@ -0,0 +1,19 @@ +import scanner from 'react-scanner'; +import reactScanner from '../reactScanner'; + +describe('reactScanner', () => { + it('should return the output of the scanner', async () => { + const mockedScannerRun = jest.spyOn(scanner, 'run'); + mockedScannerRun.mockResolvedValue('scanner output'); + + const config = { + crawlFrom: 'path/to/crawl/from', + config: 'path/to/config', + }; + + const output = await reactScanner(config); + + expect(output).toBe('scanner output'); + expect(mockedScannerRun).toHaveBeenCalledWith(config); + }); +}); diff --git a/packages/analytics/src/scanners/__tests__/twigScanner.test.ts b/packages/analytics/src/scanners/__tests__/twigScanner.test.ts new file mode 100644 index 0000000000..b989210374 --- /dev/null +++ b/packages/analytics/src/scanners/__tests__/twigScanner.test.ts @@ -0,0 +1,224 @@ +import { fs, path } from 'zx'; +import * as twigScanner from '../twigScanner'; + +describe('twigScanner', () => { + describe('getComponentsFromDirectory', () => { + it('should return an array of component names, capitalize first letter and remove `.twig` extension', () => { + const mockedReaddirSync = jest + .spyOn(fs, 'readdirSync') + // @ts-expect-error TS2322: Type 'string' is not assignable to type 'Dirent'. + .mockReturnValue(['component1.twig', 'component2.twig', 'component3.twig']); + const directoryPath = '/path/to/directory'; + const expectedComponents = ['Component1', 'Component2', 'Component3']; + + const result = twigScanner.getComponentsFromDirectory(directoryPath); + + expect(result).toEqual(expectedComponents); + expect(mockedReaddirSync).toHaveBeenCalledWith(directoryPath); + }); + }); + + describe('getPathsFromYamlConfig', () => { + it('should return an array of component names, capitalize first letter and remove `.twig` extension', () => { + const twigConfig = ` + spirit_web_twig: + paths: + - "%kernel.project_dir%/app/Resources/views" + - "%kernel.project_dir%/../spirit-web-twig-bundle/docs/twig-components" + icons: + paths: + - "%kernel.project_dir%/../spirit-web-twig-bundle/static" + `; + const mockedReadFileSync = jest.spyOn(fs, 'readFileSync').mockReturnValue(twigConfig); + const configFile = '/path/to/config/file'; + const expectedResult = [ + './app/Resources/views/*.twig', + './../spirit-web-twig-bundle/docs/twig-components/*.twig', + './../spirit-web-twig-bundle/static/*.twig', + ]; + + const result = twigScanner.getPathsFromYamlConfig(configFile); + + expect(result).toEqual(expectedResult); + expect(mockedReadFileSync).toHaveBeenCalledWith(configFile, 'utf8'); + }); + }); + + describe('determineModuleNameFromComponents', () => { + it('should return "local_component" if the nodeName is in the localComponents array', () => { + const nodeName = 'Component1'; + const localComponents = ['Component1', 'Component2', 'Component3']; + const baseComponents = ['BaseComponent1', 'BaseComponent2']; + + const result = twigScanner.determineModuleNameFromComponents(nodeName, localComponents, baseComponents); + + expect(result).toBe('local_component'); + }); + + it('should return "@lmc-eu/spirit-web-twig" if the nodeName is in the baseComponents array', () => { + const nodeName = 'BaseComponent2'; + const localComponents = ['Component1', 'Component2', 'Component3']; + const baseComponents = ['BaseComponent1', 'BaseComponent2']; + + const result = twigScanner.determineModuleNameFromComponents(nodeName, localComponents, baseComponents); + + expect(result).toBe('@lmc-eu/spirit-web-twig'); + }); + + it('should return "html_element" if the nodeName is not in the localComponents or baseComponents array', () => { + const nodeName = 'UnknownComponent'; + const localComponents = ['Component1', 'Component2', 'Component3']; + const baseComponents = ['BaseComponent1', 'BaseComponent2']; + + const result = twigScanner.determineModuleNameFromComponents(nodeName, localComponents, baseComponents); + + expect(result).toBe('html_element'); + }); + }); + + describe('searchFileForComponents', () => { + it('should return an empty object if file content is empty', () => { + const file = ''; + const localComponents: Array = []; + const baseComponents: Array = []; + jest.spyOn(fs, 'readFileSync').mockReturnValue(file); + + const result = twigScanner.searchFileForComponents(file, localComponents, baseComponents); + + expect(result).toEqual({}); + }); + + it('should return the correct components with their props', () => { + const file = 'app/Resources/views/.../card.twig'; + const fileContent = ` + + + + `; + const localComponents = ['Button', 'Input', 'CustomComponent']; + const baseComponents: Array = []; + jest.spyOn(fs, 'readFileSync').mockReturnValue(fileContent); + + const result = twigScanner.searchFileForComponents(file, localComponents, baseComponents); + + expect(result).toEqual({ + 'local_component:Button': [ + { + path: 'app/Resources/views/.../card.twig:2', + props: { + color: 'primary', + size: 'small', + }, + }, + ], + 'local_component:CustomComponent': [ + { + path: 'app/Resources/views/.../card.twig:4', + props: { + prop1: 'value1', + prop2: 'value2', + }, + }, + ], + 'local_component:Input': [ + { + path: 'app/Resources/views/.../card.twig:3', + props: { + placeholder: 'Enter your name', + type: 'text', + }, + }, + ], + }); + }); + }); + + describe('searchDirectoryForComponents', () => { + it('should return an empty object if the directory is excluded', () => { + const dir = '/path/to/excluded/directory'; + const localComponents: Array = []; + const baseComponents: Array = []; + const exclude: Array = ['/path/to/excluded/directory']; + + const lstatResult = { isDirectory: () => false } as fs.Stats; + const dirContent: fs.Dirent[] = ['file1.twig', 'file2.twig'] as unknown as fs.Dirent[]; + jest.spyOn(fs, 'readdirSync').mockReturnValue(dirContent); + jest.spyOn(fs, 'lstatSync').mockReturnValue(lstatResult); + jest.spyOn(path, 'extname').mockReturnValue('.twig'); + jest.spyOn(path, 'basename').mockReturnValue(dir); + jest.spyOn(path, 'join').mockImplementation((directory, file) => `${directory}/${file}`); + + const result = twigScanner.searchDirectoryForComponents(dir, localComponents, baseComponents, exclude); + + expect(result).toEqual({}); + }); + + it('should return an empty object if the directory is empty', () => { + const dir = '/path/to/empty/directory'; + const localComponents: Array = []; + const baseComponents: Array = []; + const exclude: Array = []; + + const lstatResult = { isDirectory: () => false } as fs.Stats; + jest.spyOn(fs, 'lstatSync').mockReturnValue(lstatResult); + jest.spyOn(path, 'extname').mockReturnValue('.twig'); + jest.spyOn(path, 'join').mockImplementation((directory, file) => `${directory}/${file}`); + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); + + const result = twigScanner.searchDirectoryForComponents(dir, localComponents, baseComponents, exclude); + + expect(result).toEqual({}); + expect(fs.readdirSync).toHaveBeenCalledWith(dir); + }); + + it('should recursively search the directory for .twig files and call searchFileForComponents', () => { + const dir = '/path/to/directory'; + const localComponents = ['Button', 'Input', 'CustomComponent']; + const baseComponents: Array = []; + const exclude: Array = []; + const lstatResult = { isDirectory: () => false } as fs.Stats; + const dirContent: fs.Dirent[] = ['file1.twig', 'file2.twig'] as unknown as fs.Dirent[]; + + const mockedReaddirSync = jest.spyOn(fs, 'readdirSync').mockReturnValue(dirContent); + const lstatSync = jest.spyOn(fs, 'lstatSync').mockReturnValue(lstatResult); + const mockedExtname = jest.spyOn(path, 'extname').mockReturnValue('.twig'); + const mockedPathJoin = jest.spyOn(path, 'join').mockImplementation((directory, file) => `${directory}/${file}`); + + const result = twigScanner.searchDirectoryForComponents(dir, localComponents, baseComponents, exclude); + + expect(result).toEqual({ + 'local_component:Button': [ + { + path: '/path/to/directory/file2.twig:2', + props: { + color: 'primary', + size: 'small', + }, + }, + ], + 'local_component:CustomComponent': [ + { + path: '/path/to/directory/file2.twig:4', + props: { + prop1: 'value1', + prop2: 'value2', + }, + }, + ], + 'local_component:Input': [ + { + path: '/path/to/directory/file2.twig:3', + props: { + placeholder: 'Enter your name', + type: 'text', + }, + }, + ], + }); + expect(mockedReaddirSync).toHaveBeenCalledWith(dir); + expect(lstatSync).toHaveBeenCalledTimes(2); + expect(mockedExtname).toHaveBeenCalledTimes(2); + expect(mockedPathJoin).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/analytics/src/scanners/reactScanner.ts b/packages/analytics/src/scanners/reactScanner.ts index a8b0d2e55e..8cefcb0188 100644 --- a/packages/analytics/src/scanners/reactScanner.ts +++ b/packages/analytics/src/scanners/reactScanner.ts @@ -1,7 +1,7 @@ import scanner from 'react-scanner'; -import { RunnerConfig } from '../types'; +import { ReactScannerConfig } from '../types'; -export default async function reactScanner(config: RunnerConfig) { +export default async function reactScanner(config: ReactScannerConfig) { const output = await scanner.run({ ...config }); return output; diff --git a/packages/analytics/src/scanners/twigScanner.ts b/packages/analytics/src/scanners/twigScanner.ts index b68a39344b..48f294d36c 100644 --- a/packages/analytics/src/scanners/twigScanner.ts +++ b/packages/analytics/src/scanners/twigScanner.ts @@ -1,11 +1,12 @@ import { fs, glob, path } from 'zx'; // Get list of names of Spirit components from twig-components directory in SPIRIT_COMPONENTS_PATH -function getComponentsFromDirectory(directoryPath: string) { - return fs.readdirSync(directoryPath).map((file: string) => file.charAt(0).toUpperCase() + file.slice(1, -5)); +export function getComponentsFromDirectory(directoryPath: string) { + // `component.twig` -> `Component` + return fs.readdirSync(directoryPath).map((file: string) => `${file.charAt(0).toUpperCase()}${file.slice(1, -5)}`); } -function getPathsFromYamlConfig(configFile: string) { +export function getPathsFromYamlConfig(configFile: string) { // Get lines of TwigX config file // Input: TWIGX_CONFIG_FILE (path) // Output example: ['spirit_web_twig:', ' paths:', ' - "%kernel.project_dir%/app/Resources/views"', ...] @@ -46,7 +47,7 @@ export async function getLocalComponentsFromPaths(paths: Array): Promise // Get module name from node name // Input: nodeName (string) // Output example: 'local_component', '@lmc-eu/spirit-web-twig', 'html_element' -function determineModuleNameFromComponents( +export function determineModuleNameFromComponents( nodeName: string, localComponents: Array, baseComponents: Array, @@ -78,7 +79,7 @@ interface Result { // Search file for adoption data and save it to result // Input: file (path) // Output example: { 'local_component:Card': [{ path: 'app/Resources/views/.../card.twig:1', props: { ... } }], ... -function searchFileForComponents(file: string, localComponents: Array, baseComponents: Array) { +export function searchFileForComponents(file: string, localComponents: Array, baseComponents: Array) { const reStartTag = /<([a-zA-Z][a-zA-Z0-9]*)([^>]*)>/g; const reAttr = /([\w-]+)="?([^"]*)"?/g; @@ -120,7 +121,7 @@ function searchFileForComponents(file: string, localComponents: Array, b // Search directory for twig files and call searchInFile for each of them // Input: dir (path) -function searchDirectoryForComponents( +export function searchDirectoryForComponents( dir: string, localComponents: Array, baseComponents: Array, @@ -145,7 +146,7 @@ function searchDirectoryForComponents( return result; } -const output = (data: unknown, destination: string) => { +export const output = (data: unknown, destination: string) => { // Create output folder if missing fs.mkdirSync(path.dirname(destination), { recursive: true }); // Save result to output file diff --git a/packages/analytics/src/spirit-analytics.config.ts b/packages/analytics/src/spirit-analytics.config.ts index faa4f32734..c6f0c852d7 100644 --- a/packages/analytics/src/spirit-analytics.config.ts +++ b/packages/analytics/src/spirit-analytics.config.ts @@ -2,6 +2,6 @@ import reactScannerConfig from './scanners/react-scanner.config'; import twigScannerConfig from './scanners/twig-scanner.config'; export default { - ...reactScannerConfig, - ...twigScannerConfig, + react: reactScannerConfig, + twig: twigScannerConfig, }; diff --git a/packages/analytics/src/types.ts b/packages/analytics/src/types.ts index a08d25ae92..49ca465022 100644 --- a/packages/analytics/src/types.ts +++ b/packages/analytics/src/types.ts @@ -45,18 +45,28 @@ interface OutputInstance { isDeprecated: boolean; } -export type TrackedData = Record; +export type TrackedData = { + react: Record; + twig: Record; +}; -export interface RunnerConfig { - crawlFrom: string; - exclude?: Array; - configFile?: string; - outputFile?: string; - coreComponentsPath?: string; +export interface ReactScannerConfig { config: string; configDir?: string; + crawlFrom: string; startTime?: string; method?: string; + exclude?: Array; +} + +export interface RunnerConfig { + crawlFrom: string; + react: ReactScannerConfig; + twig: { + configFile?: string; + outputFile?: string; + coreComponentsPath?: string; + }; } export type ScannerType = 'react' | 'twig' | null;