ctrlshiftleft
Version:
AI-powered toolkit for embedding QA and security testing into development workflows
447 lines (398 loc) • 12.9 kB
text/typescript
/**
* Platform Utilities
*
* Utilities for cross-platform compatibility to ensure consistent behavior
* across different operating systems (Windows, macOS, Linux).
*/
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
/**
* Line ending types for file generation
*/
export type LineEnding = 'auto' | 'lf' | 'crlf';
/**
* Line ending characters by type
*/
export const LINE_ENDINGS = {
lf: '\n',
crlf: '\r\n',
auto: os.platform() === 'win32' ? '\r\n' : '\n'
};
/**
* Cross-platform file path separator functions
*/
export class PathUtils {
/**
* Normalize path separators for the current platform
* @param filePath File path to normalize
* @returns Normalized path
*/
static normalizePath(filePath: string): string {
return filePath.replace(/[/\\]+/g, path.sep);
}
/**
* Ensure a path uses forward slashes (useful for URL or config paths)
* @param filePath File path to normalize
* @returns Path with forward slashes
*/
static forwardSlashes(filePath: string): string {
return filePath.replace(/\\/g, '/');
}
/**
* Convert a path to an absolute path, handling both relative and absolute inputs
* @param filePath File path to resolve
* @param basePath Base path for resolving relative paths (defaults to current directory)
* @returns Absolute path
*/
static toAbsolutePath(filePath: string, basePath = process.cwd()): string {
if (path.isAbsolute(filePath)) {
return this.normalizePath(filePath);
}
return this.normalizePath(path.resolve(basePath, filePath));
}
/**
* Get a relative path from one location to another, handling cross-platform differences
* @param from Source path
* @param to Destination path
* @returns Relative path
*/
static getRelativePath(from: string, to: string): string {
const relativePath = path.relative(from, to);
return this.normalizePath(relativePath);
}
/**
* Ensure a directory exists, creating it if necessary
* @param dirPath Directory path to ensure
* @returns True if directory exists or was created
*/
static ensureDir(dirPath: string): boolean {
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error creating directory ${dirPath}: ${errorMessage}`);
return false;
}
}
/**
* Get a platform-appropriate temporary directory path
* @param subdir Optional subdirectory name
* @returns Path to temporary directory
*/
static getTempDir(subdir?: string): string {
const tempBase = os.tmpdir();
const dirPath = subdir ? path.join(tempBase, subdir) : tempBase;
this.ensureDir(dirPath);
return dirPath;
}
/**
* Determine if a path exists and is a directory
* @param dirPath Path to check
* @returns True if path is a directory
*/
static isDirectory(dirPath: string): boolean {
try {
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
} catch (error) {
return false;
}
}
/**
* Determine if a path exists and is a file
* @param filePath Path to check
* @returns True if path is a file
*/
static isFile(filePath: string): boolean {
try {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
} catch (error) {
return false;
}
}
}
/**
* Cross-platform file content utilities
*/
export class FileUtils {
/**
* Ensure file content has consistent line endings
* @param content File content to normalize
* @param lineEnding Line ending type to use
* @returns Normalized content
*/
static normalizeLineEndings(content: string, lineEnding: LineEnding = 'auto'): string {
// First convert all line endings to LF
const normalized = content.replace(/\r\n|\r/g, '\n');
// Then convert to the desired line ending
if (lineEnding === 'crlf') {
return normalized.replace(/\n/g, '\r\n');
}
if (lineEnding === 'auto' && os.platform() === 'win32') {
return normalized.replace(/\n/g, '\r\n');
}
return normalized;
}
/**
* Write file with consistent line endings
* @param filePath Path to write to
* @param content Content to write
* @param lineEnding Line ending type to use
* @returns True if successful
*/
static writeFile(filePath: string, content: string, lineEnding: LineEnding = 'auto'): boolean {
try {
const normalized = this.normalizeLineEndings(content, lineEnding);
const dir = path.dirname(filePath);
// Ensure directory exists
if (!PathUtils.ensureDir(dir)) {
return false;
}
fs.writeFileSync(filePath, normalized);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error writing file ${filePath}: ${errorMessage}`);
return false;
}
}
/**
* Read file and normalize line endings
* @param filePath Path to read from
* @param lineEnding Line ending type to use
* @returns File content or null if error
*/
static readFile(filePath: string, lineEnding: LineEnding = 'auto'): string | null {
try {
if (!PathUtils.isFile(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, 'utf8');
return this.normalizeLineEndings(content, lineEnding);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error reading file ${filePath}: ${errorMessage}`);
return null;
}
}
/**
* Append to file with consistent line endings
* @param filePath Path to append to
* @param content Content to append
* @param lineEnding Line ending type to use
* @returns True if successful
*/
static appendFile(filePath: string, content: string, lineEnding: LineEnding = 'auto'): boolean {
try {
let normalized = this.normalizeLineEndings(content, lineEnding);
// Add a line break at the beginning if file exists and doesn't end with one
if (PathUtils.isFile(filePath)) {
const existing = fs.readFileSync(filePath, 'utf8');
const ending = lineEnding === 'crlf' ? '\r\n' :
(lineEnding === 'auto' && os.platform() === 'win32') ? '\r\n' : '\n';
if (!existing.endsWith(ending)) {
normalized = ending + normalized;
}
}
const dir = path.dirname(filePath);
// Ensure directory exists
if (!PathUtils.ensureDir(dir)) {
return false;
}
fs.appendFileSync(filePath, normalized);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error appending to file ${filePath}: ${errorMessage}`);
return false;
}
}
}
/**
* Cross-platform test output generator
*/
export class TestOutputUtils {
/**
* Generate a platform-appropriate path for test outputs
* @param componentPath Path to component being tested
* @param outputBase Base directory for output
* @param fileExtension File extension for output file
* @returns Path to test output file
*/
static getTestOutputPath(
componentPath: string,
outputBase: string = 'tests',
fileExtension: string = '.spec.ts'
): string {
// Extract component name from path
const componentName = path.basename(componentPath, path.extname(componentPath));
// Handle different naming conventions:
// foo-bar.jsx -> FooBar.spec.ts or foo-bar.spec.ts
const camelCaseName = componentName
.split(/[-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
// Ensure output directory exists
const outputDir = path.join(process.cwd(), outputBase);
PathUtils.ensureDir(outputDir);
// Create component subdirectory for better organization
const componentDir = path.join(outputDir, camelCaseName);
PathUtils.ensureDir(componentDir);
// Return path with appropriate extension
return path.join(componentDir, `${camelCaseName}${fileExtension}`);
}
/**
* Generate configuration file with platform-appropriate paths
* @param configType Type of configuration (playwright, jest)
* @param outputDir Output directory
* @param lineEnding Line ending type to use
* @returns Generated configuration content
*/
static generateTestConfig(
configType: 'playwright' | 'jest',
outputDir: string,
lineEnding: LineEnding = 'auto'
): string {
// Normalize path for the config file
const normalizedOutputDir = PathUtils.forwardSlashes(outputDir);
let content = '';
if (configType === 'playwright') {
content = `import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './',
outputDir: '../test-results',
timeout: 30000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', { outputFolder: '../test-report' }]],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});`;
} else {
content = `module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
moduleNameMapper: {
'\\\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['./jest.setup.js'],
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/mocks/**',
],
coverageDirectory: '../coverage',
};`;
}
return FileUtils.normalizeLineEndings(content, lineEnding);
}
}
/**
* Cross-platform shell command utilities
*/
export class ShellUtils {
/**
* Get platform-appropriate shell command
* @param command Base command
* @param args Command arguments
* @returns Platform-specific command string
*/
static getCommand(command: string, args: string[] = []): string {
const isWindows = os.platform() === 'win32';
// Handle commands that need special treatment on Windows
if (isWindows) {
// Common commands that need to be mapped on Windows
const windowsCommandMap: Record<string, string> = {
'rm': 'del',
'cp': 'copy',
'mv': 'move',
'ls': 'dir',
'cat': 'type',
'mkdir': 'md',
'rmdir': 'rd',
'touch': 'echo.>',
'grep': 'findstr'
};
// Map command if needed
const mappedCommand = windowsCommandMap[command] || command;
// Build the command string
return [mappedCommand, ...args].join(' ');
}
// For non-Windows platforms, just join the command and args
return [command, ...args].join(' ');
}
/**
* Get platform-appropriate file path for a command
* @param filePath File path to use in command
* @returns Formatted file path string
*/
static getCommandPath(filePath: string): string {
const isWindows = os.platform() === 'win32';
// On Windows, wrap paths with spaces in quotes
if (isWindows && filePath.includes(' ')) {
return `"${filePath}"`;
}
// On Unix-like systems, escape spaces
return filePath.replace(/ /g, '\\ ');
}
/**
* Get platform-appropriate npm script command
* @param scriptName NPM script name
* @param args Script arguments
* @returns Platform-specific npm command string
*/
static getNpmCommand(scriptName: string, args: string[] = []): string {
const isWindows = os.platform() === 'win32';
const npmCmd = isWindows ? 'npm.cmd' : 'npm';
// Build the command string
return [npmCmd, 'run', scriptName, '--', ...args].join(' ');
}
}
/**
* Detect the operating system and provide platform information
*/
export function getPlatformInfo(): {
platform: string;
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
arch: string;
pathSeparator: string;
lineEnding: string;
} {
const platform = os.platform();
return {
platform,
isWindows: platform === 'win32',
isMac: platform === 'darwin',
isLinux: platform === 'linux',
arch: os.arch(),
pathSeparator: path.sep,
lineEnding: platform === 'win32' ? '\r\n' : '\n'
};
}