@devicecloud.dev/dcd
Version:
Better cloud maestro testing
287 lines (286 loc) • 12.6 kB
JavaScript
;
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,
};
}