Skip to content

Commit 55ebc06

Browse files
authored
Merge branch 'dev' into dependabot/github_actions/dev/actions-a9bcc878af
2 parents fd64ee8 + 13d01ee commit 55ebc06

3 files changed

Lines changed: 237 additions & 36 deletions

File tree

src/services/execution-plan.service.ts

Lines changed: 69 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from 'node:path';
44
import {
55
getFlowsToRunInSequence,
66
isFlowFile,
7+
isWorkspaceConfigFile,
78
processDependencies,
89
readDirectory,
910
readTestYamlFileAsJson,
@@ -198,16 +199,18 @@ function extractDeviceCloudOverrides(
198199
/**
199200
* Generate execution plan for a single flow file
200201
* @param normalizedInput - Normalized path to the flow file
201-
* @param configFile - Optional custom config file path
202+
* @param resolvedConfigFile - Optional absolute path to a custom config file
202203
* @returns Execution plan for the single file with dependencies
203204
*/
204205
async function planSingleFile(
205206
normalizedInput: string,
206-
configFile?: string,
207+
resolvedConfigFile?: string,
207208
): Promise<IExecutionPlan> {
209+
const inputBasename = path.basename(normalizedInput);
208210
if (
209-
normalizedInput.endsWith('config.yaml') ||
210-
normalizedInput.endsWith('config.yml')
211+
inputBasename === 'config.yaml' ||
212+
inputBasename === 'config.yml' ||
213+
isWorkspaceConfigFile(normalizedInput)
211214
) {
212215
throw new Error(
213216
'If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config',
@@ -224,13 +227,14 @@ async function planSingleFile(
224227
}
225228

226229
let workspaceConfig: IWorkspaceConfig | undefined;
227-
if (configFile) {
228-
const configFilePath = path.resolve(process.cwd(), configFile);
229-
if (!fs.existsSync(configFilePath)) {
230-
throw new Error(`Config file does not exist: ${configFilePath}`);
230+
if (resolvedConfigFile) {
231+
if (!fs.existsSync(resolvedConfigFile)) {
232+
throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
231233
}
232234

233-
workspaceConfig = readYamlFileAsJson(configFilePath) as IWorkspaceConfig;
235+
workspaceConfig = readYamlFileAsJson(
236+
resolvedConfigFile,
237+
) as IWorkspaceConfig;
234238
}
235239

236240
const checkedDependancies = await checkDependencies(normalizedInput);
@@ -249,17 +253,32 @@ async function planSingleFile(
249253
* @param workspaceConfig - Workspace configuration containing flow globs
250254
* @param normalizedInput - Normalized path to the workspace directory
251255
* @param unfilteredFlowFiles - List of all discovered flow files
252-
* @param configFile - Optional custom config file path
256+
* @param resolvedConfigFile - Optional absolute path to a custom config file
253257
* @param excludeFlows - --exclude-flows patterns to re-apply to glob matches
254258
* @returns Filtered list of flow file paths matching the globs
255259
*/
256260
async function applyFlowGlobs(
257261
workspaceConfig: IWorkspaceConfig,
258262
normalizedInput: string,
259263
unfilteredFlowFiles: string[],
260-
configFile?: string,
264+
resolvedConfigFile?: string,
261265
excludeFlows?: string[],
262266
): Promise<string[]> {
267+
// Both branches compare absolute paths, so `--config ./x.yml` and
268+
// `--config /abs/x.yml` behave identically, and a same-named file in a
269+
// sibling directory is never mistaken for the active config.
270+
const activeConfig = resolvedConfigFile
271+
? path.normalize(resolvedConfigFile)
272+
: undefined;
273+
const isExcludedConfig = (absolutePath: string): boolean => {
274+
const base = path.basename(absolutePath);
275+
if (base === 'config.yaml' || base === 'config.yml') return true;
276+
return (
277+
activeConfig !== undefined &&
278+
path.normalize(absolutePath) === activeConfig
279+
);
280+
};
281+
263282
if (workspaceConfig.flows) {
264283
const globs = workspaceConfig.flows.map((g) => g);
265284
// fs.globSync lands in Node 22; the CLI's `engines.node` already requires it.
@@ -273,31 +292,19 @@ async function applyFlowGlobs(
273292
}
274293
});
275294

295+
// Resolve before filtering: glob matches are relative to normalizedInput,
296+
// so comparing them against the config path only ever worked when the
297+
// config happened to sit at the workspace root.
276298
const globbedFlowFiles = matchedFiles
277-
.filter((file: string) => {
278-
if (file === 'config.yaml' || file === 'config.yml') return false;
279-
if (configFile && file === path.basename(configFile)) return false;
280-
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) return false;
281-
const pathParts = file.split(path.sep);
282-
for (const part of pathParts) {
283-
if (part.endsWith('.app')) return false;
284-
}
285-
286-
return true;
287-
})
288-
.map((file) => path.resolve(normalizedInput, file));
299+
.map((file) => path.resolve(normalizedInput, file))
300+
.filter((file) => !isExcludedConfig(file) && isFlowFile(file));
289301

290302
// Re-globbing from disk bypasses the earlier --exclude-flows filter, so
291303
// re-apply it here or excluded flows sneak back in via `flows:` globs.
292304
return filterFlowFiles(globbedFlowFiles, excludeFlows);
293305
}
294306

295-
return unfilteredFlowFiles.filter(
296-
(file) =>
297-
!file.endsWith('config.yaml') &&
298-
!file.endsWith('config.yml') &&
299-
(!configFile || !file.endsWith(configFile)),
300-
);
307+
return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file));
301308
}
302309

303310
/**
@@ -382,6 +389,9 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
382389
} = options;
383390
const normalizedInput = path.normalize(input);
384391
const flowMetadata: Record<string, Record<string, unknown>> = {};
392+
const resolvedConfigFile = configFile
393+
? path.resolve(process.cwd(), configFile)
394+
: undefined;
385395

386396
if (!fs.existsSync(normalizedInput)) {
387397
throw new Error(
@@ -390,7 +400,7 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
390400
}
391401

392402
if (fs.lstatSync(normalizedInput).isFile()) {
393-
return planSingleFile(normalizedInput, configFile);
403+
return planSingleFile(normalizedInput, resolvedConfigFile);
394404
}
395405

396406
let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
@@ -405,13 +415,14 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
405415
unfilteredFlowFiles = filterFlowFiles(unfilteredFlowFiles, excludeFlows);
406416

407417
let workspaceConfig: IWorkspaceConfig;
408-
if (configFile) {
409-
const configFilePath = path.resolve(process.cwd(), configFile);
410-
if (!fs.existsSync(configFilePath)) {
411-
throw new Error(`Config file does not exist: ${configFilePath}`);
418+
if (resolvedConfigFile) {
419+
if (!fs.existsSync(resolvedConfigFile)) {
420+
throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
412421
}
413422

414-
workspaceConfig = readYamlFileAsJson(configFilePath) as IWorkspaceConfig;
423+
workspaceConfig = readYamlFileAsJson(
424+
resolvedConfigFile,
425+
) as IWorkspaceConfig;
415426
} else {
416427
workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
417428
}
@@ -420,10 +431,32 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
420431
workspaceConfig,
421432
normalizedInput,
422433
unfilteredFlowFiles,
423-
configFile,
434+
resolvedConfigFile,
424435
excludeFlows,
425436
);
426437

438+
// The exclusions above are filename-based: they only catch
439+
// `config.yaml`/`config.yml` and the active --config target. Any other
440+
// workspace config sharing the folder (a second CI workflow's, say) is still
441+
// on the list and would be parsed as a flow, so drop config-shaped files by
442+
// shape — see dcd-cli#99.
443+
const configShapedFiles = new Set(
444+
unfilteredFlowFiles.filter((file) => isWorkspaceConfigFile(file)),
445+
);
446+
if (configShapedFiles.size > 0) {
447+
if (debug) {
448+
console.log(
449+
`[DEBUG] Skipping ${configShapedFiles.size} workspace config file(s): ${[
450+
...configShapedFiles,
451+
].join(', ')}`,
452+
);
453+
}
454+
455+
unfilteredFlowFiles = unfilteredFlowFiles.filter(
456+
(file) => !configShapedFiles.has(file),
457+
);
458+
}
459+
427460
if (unfilteredFlowFiles.length === 0) {
428461
const error = workspaceConfig.flows
429462
? new Error(

src/services/execution-plan.utils.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,53 @@ export function isFlowFile(filePath: string): boolean {
6060
return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
6161
}
6262

63+
/**
64+
* Top-level keys that only ever appear in a workspace config (see
65+
* IWorkspaceConfig in execution-plan.service.ts). Deliberately excludes keys
66+
* Maestro also allows in flow front matter — appId, name, tags, env,
67+
* onFlowStart, onFlowComplete, jsEngine.
68+
*/
69+
const WORKSPACE_CONFIG_KEYS = new Set([
70+
'excludeTags',
71+
'executionOrder',
72+
'flows',
73+
'includeTags',
74+
'local',
75+
'notifications',
76+
'platform',
77+
]);
78+
79+
/**
80+
* True when a YAML file is a workspace config rather than a runnable flow.
81+
*
82+
* A flow is either `front matter --- steps` or a bare steps array; a
83+
* single-document top-level map carrying workspace-config keys is neither, and
84+
* left in the flow list it blows up processDependencies with "Expected an array
85+
* of steps". Detection is by shape, not filename, so several named configs can
86+
* coexist in one folder (dcd-cli#99). Requiring a recognised config key — not
87+
* just "single document, top-level map" — keeps a flow that is merely *missing*
88+
* its `---` separator loud rather than silently dropped.
89+
*
90+
* @param filePath - Path to the YAML file to classify
91+
* @returns Whether the file is a workspace config rather than a flow
92+
*/
93+
export function isWorkspaceConfigFile(filePath: string): boolean {
94+
let parsed;
95+
try {
96+
parsed = readTestYamlFileAsJson(filePath);
97+
} catch {
98+
// Unparseable — leave it in the flow list so the existing error path reports it.
99+
return false;
100+
}
101+
102+
const { config, testSteps } = parsed;
103+
if (config !== null) return false; // has `---` front matter → flow
104+
if (Array.isArray(testSteps)) return false; // bare steps array → flow
105+
if (!testSteps || typeof testSteps !== 'object') return false;
106+
107+
return Object.keys(testSteps).some((key) => WORKSPACE_CONFIG_KEYS.has(key));
108+
}
109+
63110
export const readYamlFileAsJson = (filePath: string) => {
64111
try {
65112
const normalizedPath = path.normalize(filePath);

test/integration/cloud.integration.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,127 @@ appId: com.example.app
287287
});
288288
});
289289

290+
// Regression cover for dcd-cli#99: config files were excluded from flow
291+
// discovery by *filename* (literal config.yaml/config.yml plus the exact
292+
// --config target), so a second workspace config sharing the folder was
293+
// parsed as a flow and blew up with "Expected an array of steps".
294+
// These must pass the flow *directory* — the --config tests below pass a
295+
// single file, which short-circuits into planSingleFile and never reaches
296+
// flow discovery.
297+
describe('workspace config discovery', () => {
298+
let workspaceDir: string;
299+
let buildConfig: string;
300+
let updateConfig: string;
301+
let globConfig: string;
302+
303+
before(() => {
304+
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-test-ws-'));
305+
306+
fs.writeFileSync(
307+
path.join(workspaceDir, 'flow.yaml'),
308+
`appId: com.example.app
309+
---
310+
- launchApp
311+
- tapOn: "Login"
312+
`,
313+
);
314+
315+
// A genuine flow whose name merely ends in "config.yaml". The old
316+
// unanchored endsWith() dropped it; it must run.
317+
fs.writeFileSync(
318+
path.join(workspaceDir, 'smoke-config.yaml'),
319+
`appId: com.example.app
320+
name: smoke-config
321+
---
322+
- launchApp
323+
`,
324+
);
325+
326+
// Two sibling workspace configs with custom names, as a folder serving
327+
// several CI workflows would have. Neither is a flow.
328+
buildConfig = path.join(workspaceDir, 'config_build.yml');
329+
fs.writeFileSync(
330+
buildConfig,
331+
`flows:
332+
- ./**/*.yaml
333+
includeTags:
334+
- build
335+
`,
336+
);
337+
338+
// Deliberately carries no tag or flow filtering, so when it is the active
339+
// --config it can't mask the bug by filtering its sibling away first.
340+
updateConfig = path.join(workspaceDir, 'config_update.yml');
341+
fs.writeFileSync(
342+
updateConfig,
343+
`platform:
344+
android:
345+
disableAnimations: true
346+
`,
347+
);
348+
349+
globConfig = path.join(workspaceDir, 'config_glob.yml');
350+
fs.writeFileSync(
351+
globConfig,
352+
`flows:
353+
- ./**/*.yaml
354+
- ./**/*.yml
355+
`,
356+
);
357+
});
358+
359+
after(() => {
360+
if (fs.existsSync(workspaceDir)) {
361+
fs.rmSync(workspaceDir, { force: true, recursive: true });
362+
}
363+
});
364+
365+
it('should ignore sibling config files not named in --config', async () => {
366+
const command = `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --config "${updateConfig}" --dry-run`;
367+
368+
const { stdout } = await exec(command, { timeout: 15_000 });
369+
expect(stdout).to.include('The following tests would have been run');
370+
expect(stdout).to.include('flow.yaml');
371+
expect(stdout).to.not.include('config_build.yml');
372+
expect(stdout).to.not.include('config_glob.yml');
373+
expect(stdout).to.not.include('Expected an array of steps');
374+
});
375+
376+
it('should ignore config-shaped files when no --config is passed', async () => {
377+
const command = `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --debug --dry-run`;
378+
379+
const { stdout } = await exec(command, { timeout: 15_000 });
380+
expect(stdout).to.include('flow.yaml');
381+
expect(stdout).to.include('[DEBUG] Skipping 3 workspace config file(s)');
382+
expect(stdout).to.not.include('Expected an array of steps');
383+
});
384+
385+
it('should ignore config-shaped files matched by a flows glob', async () => {
386+
const command = `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --config "${globConfig}" --dry-run`;
387+
388+
const { stdout } = await exec(command, { timeout: 15_000 });
389+
expect(stdout).to.include('flow.yaml');
390+
expect(stdout).to.not.include('config_build.yml');
391+
expect(stdout).to.not.include('config_update.yml');
392+
expect(stdout).to.not.include('Expected an array of steps');
393+
});
394+
395+
it('should run a flow whose filename merely ends in config.yaml', async () => {
396+
const command = `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --config "${updateConfig}" --dry-run`;
397+
398+
const { stdout } = await exec(command, { timeout: 15_000 });
399+
expect(stdout).to.include('smoke-config.yaml');
400+
});
401+
402+
it('should reject a custom-named config passed as the flow input', async () => {
403+
const command = `${CLI} cloud ${androidAppFile} "${buildConfig}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`;
404+
405+
const { output } = await runExpectingFailure(command);
406+
expect(output).to.include('pass the workspace folder path');
407+
expect(output).to.not.include('Expected an array of steps');
408+
});
409+
});
410+
290411
describe('file and binary management', () => {
291412
it('should support app binary ID instead of file', async () => {
292413
const command = `${CLI} cloud --app-binary-id test-binary-123 ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`;

0 commit comments

Comments
 (0)