UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

355 lines • 12.7 kB
import { fileExists, readFile } from '../fs.js'; import { isAbsolutePath, joinPath, relativePath } from '../path.js'; import { execCommand, captureCommandWithExitCode } from '../system.js'; /** * Base class for doctor test suites. * * Write tests using the test() method:. * * ```typescript * export default class MyTests extends DoctorSuite { * static description = 'My test suite' * * tests() { * this.test('basic case', async () => { * const result = await this.run('shopify theme init') * this.assertSuccess(result) * }) * * this.test('error case', async () => { * const result = await this.run('shopify theme init --invalid') * this.assertError(result, /unknown flag/) * }) * } * } * ``` */ export class DoctorSuite { constructor() { this.assertions = []; this.registeredTests = []; } /** * Run the entire test suite. * * @param context - The doctor context for this suite run. */ async runSuite(context) { this.context = context; this.registeredTests = []; const results = []; // Call tests() to register tests via this.test() this.tests(); // Run all registered tests for (const registeredTest of this.registeredTests) { this.assertions = []; const startTime = Date.now(); try { // eslint-disable-next-line no-await-in-loop await registeredTest.fn(); results.push({ name: registeredTest.name, status: this.hasFailures() ? 'failed' : 'passed', duration: Date.now() - startTime, assertions: [...this.assertions], }); // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { results.push({ name: registeredTest.name, status: 'failed', duration: Date.now() - startTime, assertions: [...this.assertions], error: error instanceof Error ? error : new Error(String(error)), }); } } return results; } /** * Register a test with a name and function. * * @param name - The test name. * @param fn - The async test function. */ test(name, fn) { this.registeredTests.push({ name, fn }); } /** * Override this method to register tests using this.test(). */ tests() { // Subclasses override this to register tests } // ============================================ // Command execution // ============================================ /** * Run a CLI command and return the result. * * @param command - The CLI command to run. * @param options - Optional cwd and env overrides. * @example * const result = await this.run('shopify theme init my-theme') * const result = await this.run('shopify theme push --json') */ async run(command, options) { const cwd = options?.cwd ?? this.context.workingDirectory; const result = await captureCommandWithExitCode(command, { cwd, env: options?.env }); return { command, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, output: String(result.stdout) + String(result.stderr), success: result.exitCode === 0, }; } /** * Run a command without capturing output (for interactive commands). * Returns only success/failure. * * @param command - The CLI command to run. * @param options - Optional cwd and env overrides. */ async runInteractive(command, options) { const cwd = options?.cwd ?? this.context.workingDirectory; let exitCode = 0; try { await execCommand(command, { cwd, env: options?.env, stdin: 'inherit' }); // eslint-disable-next-line no-catch-all/no-catch-all } catch { exitCode = 1; } return { command, exitCode, stdout: '', stderr: '', output: '', success: exitCode === 0, }; } // ============================================ // Assertions // ============================================ /** * Assert that a command succeeded (exit code 0). * * @param result - The command result to check. * @param message - Optional custom assertion message. */ assertSuccess(result, message) { this.assertions.push({ description: message ?? `Command succeeded: ${result.command}`, passed: result.success, expected: 'exit code 0', actual: `exit code ${result.exitCode}`, }); } /** * Assert that a command failed with an error matching the pattern. * * @param result - The command result to check. * @param pattern - Optional regex or string pattern to match against output. * @param message - Optional custom assertion message. */ assertError(result, pattern, message) { const failed = !result.success; if (pattern) { const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; const matches = regex.test(result.output); let actualValue; if (!failed) { actualValue = 'command succeeded'; } else if (matches) { actualValue = 'matched'; } else { actualValue = `output: ${result.output.slice(0, 200)}`; } this.assertions.push({ description: message ?? `Command failed with expected error: ${pattern}`, passed: failed && matches, expected: `failure with error matching ${pattern}`, actual: actualValue, }); } else { this.assertions.push({ description: message ?? `Command failed: ${result.command}`, passed: failed, expected: 'non-zero exit code', actual: `exit code ${result.exitCode}`, }); } } /** * Assert that a file exists and optionally matches content. * * @param path - The file path to check. * @param contentPattern - Optional regex or string to match file content. * @param message - Optional custom assertion message. */ async assertFile(path, contentPattern, message) { const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path); const displayPath = relativePath(this.context.workingDirectory, fullPath); const exists = await fileExists(fullPath); if (!exists) { this.assertions.push({ description: message ?? `File exists: ${displayPath}`, passed: false, expected: 'file exists', actual: 'file not found', }); return; } if (contentPattern) { const content = await readFile(fullPath); const regex = typeof contentPattern === 'string' ? new RegExp(contentPattern) : contentPattern; const matches = regex.test(content); this.assertions.push({ description: message ?? `File ${displayPath} matches ${contentPattern}`, passed: matches, expected: `content matching ${contentPattern}`, actual: matches ? 'matched' : `content: ${content.slice(0, 200)}...`, }); } else { this.assertions.push({ description: message ?? `File exists: ${displayPath}`, passed: true, expected: 'file exists', actual: 'file exists', }); } } /** * Assert that a file does not exist. * * @param path - The file path to check. * @param message - Optional custom assertion message. */ async assertNoFile(path, message) { const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path); const displayPath = relativePath(this.context.workingDirectory, fullPath); const exists = await fileExists(fullPath); this.assertions.push({ description: message ?? `File does not exist: ${displayPath}`, passed: !exists, expected: 'file does not exist', actual: exists ? 'file exists' : 'file does not exist', }); } /** * Assert that a directory exists. * * @param path - The directory path to check. * @param message - Optional custom assertion message. */ async assertDirectory(path, message) { const fullPath = isAbsolutePath(path) ? path : joinPath(this.context.workingDirectory, path); const displayPath = relativePath(this.context.workingDirectory, fullPath); const exists = await fileExists(fullPath); this.assertions.push({ description: message ?? `Directory exists: ${displayPath}`, passed: exists, expected: 'directory exists', actual: exists ? 'directory exists' : 'directory not found', }); } /** * Assert that output contains a pattern. * * @param result - The command result to check. * @param pattern - Regex or string pattern to match against output. * @param message - Optional custom assertion message. */ assertOutput(result, pattern, message) { const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; const matches = regex.test(result.output); this.assertions.push({ description: message ?? `Output matches ${pattern}`, passed: matches, expected: `output matching ${pattern}`, actual: matches ? 'matched' : `output: ${result.output.slice(0, 200)}`, }); } /** * Assert that output contains valid JSON and optionally validate it. * * @param result - The command result to parse. * @param validator - Optional function to validate the parsed JSON. * @param message - Optional custom assertion message. */ assertJson(result, validator, message) { try { const json = JSON.parse(result.stdout); if (validator) { const valid = validator(json); this.assertions.push({ description: message ?? 'Output is valid JSON matching validator', passed: valid, expected: 'valid JSON matching validator', actual: valid ? 'matched' : 'validator returned false', }); } else { this.assertions.push({ description: message ?? 'Output is valid JSON', passed: true, expected: 'valid JSON', actual: 'valid JSON', }); } return json; // eslint-disable-next-line no-catch-all/no-catch-all } catch { this.assertions.push({ description: message ?? 'Output is valid JSON', passed: false, expected: 'valid JSON', actual: `invalid JSON: ${result.stdout.slice(0, 100)}`, }); return undefined; } } /** * Assert a boolean condition. * * @param condition - The boolean condition to assert. * @param message - The assertion description. */ assert(condition, message) { this.assertions.push({ description: message, passed: condition, expected: 'true', actual: String(condition), }); } /** * Assert two values are equal. * * @param actual - The actual value. * @param expected - The expected value. * @param message - The assertion description. */ assertEqual(actual, expected, message) { this.assertions.push({ description: message, passed: actual === expected, expected: String(expected), actual: String(actual), }); } hasFailures() { return this.assertions.some((assertion) => !assertion.passed); } } DoctorSuite.description = 'Doctor test suite'; //# sourceMappingURL=framework.js.map