Skip to content

Commit

Permalink
feat (codemod): Ensure ts, tsx, jsx test fixtures use valid syntax.
Browse files Browse the repository at this point in the history
  • Loading branch information
shaper committed Nov 21, 2024
1 parent ad57459 commit 6421321
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 116 deletions.
1 change: 1 addition & 0 deletions packages/codemod/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"scaffold": "tsx scripts/scaffold-codemod.ts"
},
"devDependencies": {
"@babel/parser": "^7.26.2",
"@types/cli-progress": "^3.11.6",
"@types/debug": "^4.1.12",
"@types/jscodeshift": "^0.12.0",
Expand Down
87 changes: 65 additions & 22 deletions packages/codemod/src/test/test-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { API, FileInfo } from 'jscodeshift';
import * as testUtils from './test-utils';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { parse } from '@babel/parser';

vi.mock('fs', () => ({
existsSync: vi.fn(),
Expand All @@ -13,6 +13,10 @@ vi.mock('path', () => ({
join: vi.fn((...args) => args.join('/')),
}));

vi.mock('@babel/parser', () => ({
parse: vi.fn(),
}));

describe('test-utils', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -87,9 +91,7 @@ describe('test-utils', () => {

describe('readFixture', () => {
const mockExistsSync = existsSync as unknown as ReturnType<typeof vi.fn>;
const mockReadFileSync = readFileSync as unknown as ReturnType<
typeof vi.fn
>;
const mockReadFileSync = readFileSync as unknown as ReturnType<typeof vi.fn>;
const mockJoin = join as unknown as ReturnType<typeof vi.fn>;

beforeEach(() => {
Expand All @@ -102,22 +104,26 @@ describe('test-utils', () => {

const result = testUtils.readFixture('test', 'input');

expect(result).toBe('ts content');
expect(result).toEqual({
content: 'ts content',
extension: '.ts'
});
expect(mockReadFileSync).toHaveBeenCalledWith(
expect.stringContaining('test.input.ts'),
'utf8',
);
});

it('should read .tsx fixture when .ts does not exist', () => {
mockExistsSync.mockImplementation((path: string) =>
path.endsWith('.tsx'),
);
mockExistsSync.mockImplementation((path: string) => path.endsWith('.tsx'));
mockReadFileSync.mockReturnValue('tsx content');

const result = testUtils.readFixture('test', 'input');

expect(result).toBe('tsx content');
expect(result).toEqual({
content: 'tsx content',
extension: '.tsx'
});
expect(mockReadFileSync).toHaveBeenCalledWith(
expect.stringContaining('test.input.tsx'),
'utf8',
Expand All @@ -133,34 +139,71 @@ describe('test-utils', () => {
});
});

describe('validateSyntax', () => {
const mockParse = parse as unknown as ReturnType<typeof vi.fn>;

it('should validate typescript syntax', () => {
mockParse.mockImplementation(() => ({}));

expect(() => testUtils.validateSyntax('const x: number = 1;', '.ts')).not.toThrow();
expect(mockParse).toHaveBeenCalledWith(
'const x: number = 1;',
expect.objectContaining({
plugins: ['typescript']
})
);
});

it('should validate tsx syntax', () => {
mockParse.mockImplementation(() => ({}));

expect(() => testUtils.validateSyntax('const x = <div />;', '.tsx')).not.toThrow();
expect(mockParse).toHaveBeenCalledWith(
'const x = <div />;',
expect.objectContaining({
plugins: ['typescript', 'jsx']
})
);
});

it('should throw on invalid syntax', () => {
mockParse.mockImplementation(() => {
throw new Error('Invalid syntax');
});

expect(() => testUtils.validateSyntax('const x =;', '.ts')).toThrow('Syntax error');
});
});

describe('testTransform', () => {
it('should compare transform output with fixture', () => {
const mockParse = parse as unknown as ReturnType<typeof vi.fn>;

beforeEach(() => {
mockParse.mockImplementation(() => ({}));
});

it('should compare transform output with fixture and validate syntax', () => {
const mockTransform = vi.fn().mockReturnValue('transformed');

// Mock filesystem for this test
(existsSync as any).mockImplementation((path: string) =>
path.endsWith('.ts'),
);
(existsSync as any).mockImplementation((path: string) => path.endsWith('.ts'));
(readFileSync as any)
.mockReturnValueOnce('input content') // First call for input
.mockReturnValueOnce('transformed'); // Second call for output
.mockReturnValueOnce('input content')
.mockReturnValueOnce('transformed');

testUtils.testTransform(mockTransform, 'test');

expect(mockTransform).toHaveBeenCalled();
expect(readFileSync).toHaveBeenCalledTimes(2);
expect(mockParse).toHaveBeenCalledTimes(2); // Validates both input and output
});

it('should throw when transform output does not match fixture', () => {
const mockTransform = vi.fn().mockReturnValue('wrong output');

// Mock filesystem for this test
(existsSync as any).mockImplementation((path: string) =>
path.endsWith('.ts'),
);
(existsSync as any).mockImplementation((path: string) => path.endsWith('.ts'));
(readFileSync as any)
.mockReturnValueOnce('input') // First call for input
.mockReturnValueOnce('expected output'); // Second call for output
.mockReturnValueOnce('input')
.mockReturnValueOnce('expected output');

expect(() => testUtils.testTransform(mockTransform, 'test')).toThrow();
});
Expand Down
109 changes: 99 additions & 10 deletions packages/codemod/src/test/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import { API, FileInfo } from 'jscodeshift';
import jscodeshift from 'jscodeshift';
import { join } from 'path';
import { existsSync, readFileSync } from 'fs';
import { parse, ParserPlugin } from '@babel/parser';

/**
* Applies a codemod transform to the input code.
*
* @param transform - The codemod transform function.
* @param input - The input source code.
* @param options - Optional transform options.
* @returns The transformed code or the original input if no changes were made.
*/
export function applyTransform(
transform: (fileInfo: FileInfo, api: API, options: any) => string | null,
input: string,
Expand All @@ -24,26 +33,106 @@ export function applyTransform(
return result === null ? input : result;
}

export function readFixture(name: string, type: 'input' | 'output'): string {
/**
* Reads a fixture file from the __testfixtures__ directory.
*
* @param name - The base name of the fixture.
* @param type - The type of fixture ('input' or 'output').
* @returns An object containing the fixture's content and its file extension.
* @throws If the fixture file is not found.
*/
export function readFixture(
name: string,
type: 'input' | 'output',
): { content: string; extension: string } {
const basePath = join(__dirname, '__testfixtures__', `${name}.${type}`);
const tsPath = `${basePath}.ts`;
const tsxPath = `${basePath}.tsx`;
const extensions = ['.ts', '.tsx', '.js', '.jsx'];

if (existsSync(tsPath)) {
return readFileSync(tsPath, 'utf8');
for (const ext of extensions) {
const fullPath = `${basePath}${ext}`;
if (existsSync(fullPath)) {
return { content: readFileSync(fullPath, 'utf8'), extension: ext };
}
}
if (existsSync(tsxPath)) {
return readFileSync(tsxPath, 'utf8');
throw new Error(
`Fixture not found: ${name}.${type} with extensions ${extensions.join(
', ',
)}`,
);
}

/**
* Determines the Babel parser plugins based on the file extension.
*
* @param extension - The file extension (e.g., '.ts', '.tsx', '.js', '.jsx').
* @returns An array of Babel parser plugins.
*/
function getPluginsForExtension(extension: string): ParserPlugin[] {
switch (extension) {
case '.ts':
return ['typescript'];
case '.tsx':
return ['typescript', 'jsx'];
case '.jsx':
return ['jsx'];
case '.js':
return []; // Add more plugins if needed
default:
return [];
}
}

/**
* Validates the syntax of the provided code using Babel's parser.
*
* @param code - The source code to validate.
* @param extension - The file extension to determine parser plugins.
* @throws If the code contains syntax errors.
*/
export function validateSyntax(code: string, extension: string): void {
const plugins = getPluginsForExtension(extension);
try {
parse(code, {
sourceType: 'module',
plugins: plugins,
});
} catch (error: any) {
throw new Error(
`Syntax error in code with extension ${extension}: ${error.message}`,
);
}
throw new Error(`Fixture not found: ${name}.${type}`);
}

/**
* Tests a codemod transform by applying it to input fixtures and comparing the output to expected fixtures.
* Additionally, validates that both input and output fixtures have valid syntax.
*
* @param transformer - The codemod transformer function.
* @param fixtureName - The base name of the fixture to test.
*/
export function testTransform(
transformer: (fileInfo: FileInfo, api: API, options: any) => string | null,
fixtureName: string,
) {
const input = readFixture(fixtureName, 'input');
const expectedOutput = readFixture(fixtureName, 'output');
// Read input and output fixtures along with their extensions
const { content: input, extension: inputExt } = readFixture(
fixtureName,
'input',
);
const { content: expectedOutput, extension: outputExt } = readFixture(
fixtureName,
'output',
);

// Validate that input code is syntactically correct
validateSyntax(input, inputExt);

// Apply the transformer to the input code
const actualOutput = applyTransform(transformer, input);

// Validate that output code is syntactically correct
validateSyntax(actualOutput, outputExt);

// Compare actual output to expected output
expect(actualOutput).toBe(expectedOutput);
}
Loading

0 comments on commit 6421321

Please sign in to comment.