UNPKG

@devicecloud.dev/dcd

Version:

Better cloud maestro testing

287 lines (286 loc) 12.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.plan = plan; const glob_1 = require("glob"); const fs = require("node:fs"); const path = require("node:path"); const execution_plan_utils_1 = require("./execution-plan.utils"); /** * Recursively check and resolve all dependencies for a flow file * Includes runFlow references, JavaScript scripts, and media files * @param input - Path to flow file to check * @returns Array of all dependency file paths (deduplicated) * @throws Error if any referenced files are missing */ async function checkDependencies(input) { const checkedDependencies = []; const uncheckedDependencies = [input]; while (uncheckedDependencies.length > 0) { const fileToCheck = uncheckedDependencies.shift(); const { config, testSteps } = (0, execution_plan_utils_1.readTestYamlFileAsJson)(fileToCheck); const { allErrors, allFiles } = (0, execution_plan_utils_1.processDependencies)({ config, input: fileToCheck, testSteps, }); if (allErrors.length > 0) { throw new Error('The following flow files are not present in the filesystem: \n' + allErrors.join('\n')); } for (const file of allFiles) { if (!(0, execution_plan_utils_1.isFlowFile)(file)) { // js/media files don't have dependencies checkedDependencies.push(file); } if (!checkedDependencies.includes(file)) { uncheckedDependencies.push(file); } } if (!checkedDependencies.includes(fileToCheck)) { checkedDependencies.push(fileToCheck); } } return checkedDependencies; } /** * Filter flow files based on exclude patterns * @param unfilteredFlowFiles - All discovered flow files * @param excludeFlows - Patterns to exclude * @returns Filtered array of flow file paths */ function filterFlowFiles(unfilteredFlowFiles, excludeFlows) { if (excludeFlows) { return unfilteredFlowFiles.filter((file) => !excludeFlows.some((flow) => path.normalize(file).startsWith(path.normalize(path.resolve(flow))))); } return unfilteredFlowFiles; } /** * Load workspace configuration from config.yaml/yml if present * @param input - Input directory path * @param unfilteredFlowFiles - List of discovered flow files * @returns Workspace configuration object (empty if no config file found) */ function getWorkspaceConfig(input, unfilteredFlowFiles) { const possibleConfigPaths = new Set([path.join(input, 'config.yaml'), path.join(input, 'config.yml')].map((p) => path.normalize(p))); const configFilePath = unfilteredFlowFiles.find((file) => possibleConfigPaths.has(path.normalize(file))); const config = configFilePath ? (0, execution_plan_utils_1.readYamlFileAsJson)(configFilePath) : {}; return config; } /** * Extract DeviceCloud-specific override environment variables * Looks for env vars prefixed with DEVICECLOUD_OVERRIDE_ * @param config - Flow configuration object * @returns Object containing override key-value pairs */ function extractDeviceCloudOverrides(config) { if (!config || !config.env || typeof config.env !== 'object') { return {}; } const overrides = {}; const envVars = config.env; for (const [key, value] of Object.entries(envVars)) { if (key.startsWith('DEVICECLOUD_OVERRIDE_')) { // Remove the DEVICECLOUD_OVERRIDE_ prefix and use the rest as the override key const overrideKey = key.replace('DEVICECLOUD_OVERRIDE_', ''); overrides[overrideKey] = value; } } return overrides; } /** * Generate execution plan for test flows * * Handles: * - Single file or directory input * - Workspace configuration (config.yaml) * - Flow inclusion/exclusion patterns * - Tag-based filtering (include/exclude) * - Dependency resolution (runFlow, scripts, media) * - Sequential execution ordering * - DeviceCloud-specific overrides * * @param options - Plan generation options * @returns Complete execution plan with flows, dependencies, and metadata * @throws Error if input path doesn't exist, no flows found, or dependencies missing */ async function plan(options) { const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, } = options; const normalizedInput = path.normalize(input); const flowMetadata = {}; if (!fs.existsSync(normalizedInput)) { throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`); } if (fs.lstatSync(normalizedInput).isFile()) { if (normalizedInput.endsWith('config.yaml') || normalizedInput.endsWith('config.yml')) { throw new Error('If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config'); } const { config } = (0, execution_plan_utils_1.readTestYamlFileAsJson)(normalizedInput); const flowOverrides = {}; if (config) { flowMetadata[normalizedInput] = config; flowOverrides[normalizedInput] = extractDeviceCloudOverrides(config); } const checkedDependancies = await checkDependencies(normalizedInput); return { flowMetadata, flowOverrides, flowsToRun: [normalizedInput], referencedFiles: [...new Set(checkedDependancies)], totalFlowFiles: 1, }; } let unfilteredFlowFiles = await (0, execution_plan_utils_1.readDirectory)(normalizedInput, execution_plan_utils_1.isFlowFile); if (unfilteredFlowFiles.length === 0) { throw new Error(`Flow directory does not contain any Flow files: ${path.resolve(normalizedInput)}`); } unfilteredFlowFiles = filterFlowFiles(unfilteredFlowFiles, excludeFlows); let workspaceConfig; if (configFile) { const configFilePath = path.resolve(process.cwd(), configFile); if (!fs.existsSync(configFilePath)) { throw new Error(`Config file does not exist: ${configFilePath}`); } workspaceConfig = (0, execution_plan_utils_1.readYamlFileAsJson)(configFilePath); } else { workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles); } if (workspaceConfig.flows) { const globs = workspaceConfig.flows.map((glob) => glob); const matchedFiles = await (0, glob_1.glob)(globs, { cwd: normalizedInput, nodir: true, }); // overwrite the list of files with the globbed ones unfilteredFlowFiles = matchedFiles .filter((file) => { // Exclude config files if (file === 'config.yaml' || file === 'config.yml') { return false; } // Exclude config file if specified if (configFile && file === path.basename(configFile)) { return false; } // Check file extension if (!file.endsWith('.yaml') && !file.endsWith('.yml')) { return false; } // Exclude files inside .app bundles // Check if any directory in the path ends with .app const pathParts = file.split(path.sep); for (const part of pathParts) { if (part.endsWith('.app')) { return false; } } return true; }) .map((file) => path.resolve(normalizedInput, file)); } else { // workspace config has no flows, so we need to remove the config file from the test list unfilteredFlowFiles = unfilteredFlowFiles.filter((file) => !file.endsWith('config.yaml') && !file.endsWith('config.yml') && (!configFile || !file.endsWith(configFile))); } if (unfilteredFlowFiles.length === 0) { const error = workspaceConfig.flows ? new Error(`Flow inclusion pattern(s) did not match any Flow files:\n${workspaceConfig.flows.join('\n')}`) : new Error(`Workspace does not contain any Flows: ${path.resolve(normalizedInput)}`); throw error; } const configPerFlowFile = // eslint-disable-next-line unicorn/no-array-reduce unfilteredFlowFiles.reduce((acc, filePath) => { const { config } = (0, execution_plan_utils_1.readTestYamlFileAsJson)(filePath); acc[filePath] = config; return acc; }, {}); const allIncludeTags = [ ...includeTags, ...(workspaceConfig.includeTags || []), ]; const allExcludeTags = [ ...excludeTags, ...(workspaceConfig.excludeTags || []), ]; const flowOverrides = {}; const allFlows = unfilteredFlowFiles.filter((filePath) => { const config = configPerFlowFile[filePath]; const rawTags = config?.tags; const tags = Array.isArray(rawTags) ? rawTags : (rawTags ? [rawTags] : []); if (config) { flowMetadata[filePath] = config; flowOverrides[filePath] = extractDeviceCloudOverrides(config); } return ((allIncludeTags.length === 0 || tags.some((tag) => allIncludeTags.includes(tag))) && (allExcludeTags.length === 0 || !tags.some((tag) => allExcludeTags.includes(tag)))); }); if (allFlows.length === 0) { throw new Error(`Include / Exclude tags did not match any Flows:\n\nInclude Tags:\n${allIncludeTags.join('\n')}\n\nExclude Tags:\n${allExcludeTags.join('\n')}`); } // Check dependencies only for the filtered flows const allFiles = await Promise.all(allFlows.map((filePath) => checkDependencies(filePath))).then((results) => [...new Set(results.flat())]); // eslint-disable-next-line unicorn/no-array-reduce const pathsByName = allFlows.reduce((acc, filePath) => { const config = configPerFlowFile[filePath]; const name = config?.name || path.parse(filePath).name; acc[name] = filePath; if (debug) { console.log(`[DEBUG] Flow name mapping: "${name}" -> ${filePath}`); } return acc; }, {}); if (debug && workspaceConfig.executionOrder?.flowsOrder) { console.log('[DEBUG] executionOrder.flowsOrder:', workspaceConfig.executionOrder.flowsOrder); console.log('[DEBUG] Available flow names:', Object.keys(pathsByName)); } const flowsToRunInSequence = workspaceConfig.executionOrder?.flowsOrder ?.map((flowOrder) => { // Strip .yaml/.yml extension only from the END of the string // This supports flowsOrder entries like "my_test.yml" matching "my_test" // while preserving extensions in the middle like "(file.yml) Name" const normalizedFlowOrder = flowOrder.replace(/\.ya?ml$/i, ''); if (debug && flowOrder !== normalizedFlowOrder) { console.log(`[DEBUG] Stripping trailing extension: "${flowOrder}" -> "${normalizedFlowOrder}"`); } return (0, execution_plan_utils_1.getFlowsToRunInSequence)(pathsByName, [normalizedFlowOrder], debug); }) .flat() || []; if (debug) { console.log(`[DEBUG] Sequential flows resolved: ${flowsToRunInSequence.length} flow(s)`); } // Validate that all specified flows were found if (workspaceConfig.executionOrder?.flowsOrder && workspaceConfig.executionOrder.flowsOrder.length > 0 && flowsToRunInSequence.length === 0) { console.warn(`Warning: executionOrder specified ${workspaceConfig.executionOrder.flowsOrder.length} flow(s) but none were found.\n` + `This may be intentional if flows were excluded by tags.\n\n` + `Expected flows: ${workspaceConfig.executionOrder.flowsOrder.join(', ')}\n` + `Available flow names: ${Object.keys(pathsByName).join(', ')}\n\n` + `Hint: Flow names come from either the 'name' field in the flow config or the filename without extension.`); } const normalFlows = allFlows .filter((flow) => !flowsToRunInSequence.includes(flow)) .sort((a, b) => a.localeCompare(b)); return { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: normalFlows, referencedFiles: [...new Set(allFiles)], sequence: { continueOnFailure: workspaceConfig.executionOrder?.continueOnFailure, flows: flowsToRunInSequence, }, totalFlowFiles: unfilteredFlowFiles.length, workspaceConfig, }; }