@devicecloud.dev/dcd 5.3.0 → 5.3.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -366,6 +366,9 @@ export const cloudCommand = defineCommand({
366
366
  excludeFlows,
367
367
  configFile,
368
368
  debug,
369
+ // Not warnOut: config problems are worth surfacing even under --json,
370
+ // and logger.warn writes to stderr so stdout stays parseable.
371
+ warn: (m) => logger.warn(m),
369
372
  });
370
373
  if (debug) {
371
374
  out(`[DEBUG] Execution plan generated`);
@@ -117,6 +117,8 @@ export function registerRunCloudTest(server) {
117
117
  excludeTags: args.excludeTags ?? [],
118
118
  excludeFlows: args.excludeFlows,
119
119
  configFile: args.configFile,
120
+ // stdout is the JSON-RPC channel — config warnings must go to stderr.
121
+ warn: logStderr,
120
122
  });
121
123
  const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles);
122
124
  const testMetadataMap = buildTestMetadataMap(executionPlan.flowMetadata, commonRoot);
@@ -1,37 +1,4 @@
1
- /** Email notification configuration */
2
- interface INotificationsConfig {
3
- email?: {
4
- enabled?: boolean;
5
- onSuccess?: boolean;
6
- recipients?: string[];
7
- };
8
- }
9
- /** Workspace configuration from config.yaml */
10
- interface IWorkspaceConfig {
11
- excludeTags?: null | string[];
12
- executionOrder?: IExecutionOrder | null;
13
- flows?: null | string[];
14
- includeTags?: null | string[];
15
- local?: ILocal | null;
16
- notifications?: INotificationsConfig;
17
- platform?: {
18
- android?: {
19
- disableAnimations?: boolean;
20
- };
21
- ios?: {
22
- disableAnimations?: boolean;
23
- };
24
- };
25
- }
26
- /** Local execution configuration */
27
- interface ILocal {
28
- deterministicOrder: boolean | null;
29
- }
30
- /** Sequential execution configuration */
31
- interface IExecutionOrder {
32
- continueOnFailure: boolean;
33
- flowsOrder: string[];
34
- }
1
+ import { IWorkspaceConfig } from './workspace-config.schema.js';
35
2
  /** Options for execution plan generation */
36
3
  export interface PlanOptions {
37
4
  configFile?: string;
@@ -40,6 +7,12 @@ export interface PlanOptions {
40
7
  excludeTags?: string[];
41
8
  includeTags?: string[];
42
9
  input: string;
10
+ /**
11
+ * Sink for non-fatal config problems. Injected rather than imported so the
12
+ * MCP server can route warnings to stderr — its stdout is the JSON-RPC
13
+ * channel.
14
+ */
15
+ warn?: (message: string) => void;
43
16
  }
44
17
  /** Execution plan containing all flows to run with metadata and dependencies */
45
18
  export interface IExecutionPlan {
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { getFlowsToRunInSequence, isFlowFile, processDependencies, readDirectory, readTestYamlFileAsJson, readYamlFileAsJson, } from './execution-plan.utils.js';
3
+ import { getFlowsToRunInSequence, isFlowFile, isWorkspaceConfigFile, loadWorkspaceConfig, processDependencies, readDirectory, readTestYamlFileAsJson, } from './execution-plan.utils.js';
4
4
  /**
5
5
  * Recursively check and resolve all dependencies for a flow file
6
6
  * Includes runFlow references, JavaScript scripts, and media files
@@ -54,15 +54,13 @@ function filterFlowFiles(unfilteredFlowFiles, excludeFlows) {
54
54
  * Load workspace configuration from config.yaml/yml if present
55
55
  * @param input - Input directory path
56
56
  * @param unfilteredFlowFiles - List of discovered flow files
57
+ * @param warn - Sink for non-fatal config problems
57
58
  * @returns Workspace configuration object (empty if no config file found)
58
59
  */
59
- function getWorkspaceConfig(input, unfilteredFlowFiles) {
60
+ function getWorkspaceConfig(input, unfilteredFlowFiles, warn) {
60
61
  const possibleConfigPaths = new Set([path.join(input, 'config.yaml'), path.join(input, 'config.yml')].map((p) => path.normalize(p)));
61
62
  const configFilePath = unfilteredFlowFiles.find((file) => possibleConfigPaths.has(path.normalize(file)));
62
- const config = configFilePath
63
- ? readYamlFileAsJson(configFilePath)
64
- : {};
65
- return config;
63
+ return configFilePath ? loadWorkspaceConfig(configFilePath, warn) : {};
66
64
  }
67
65
  /**
68
66
  * Extract DeviceCloud-specific override environment variables
@@ -88,12 +86,15 @@ function extractDeviceCloudOverrides(config) {
88
86
  /**
89
87
  * Generate execution plan for a single flow file
90
88
  * @param normalizedInput - Normalized path to the flow file
91
- * @param configFile - Optional custom config file path
89
+ * @param warn - Sink for non-fatal config problems
90
+ * @param resolvedConfigFile - Optional absolute path to a custom config file
92
91
  * @returns Execution plan for the single file with dependencies
93
92
  */
94
- async function planSingleFile(normalizedInput, configFile) {
95
- if (normalizedInput.endsWith('config.yaml') ||
96
- normalizedInput.endsWith('config.yml')) {
93
+ async function planSingleFile(normalizedInput, warn, resolvedConfigFile) {
94
+ const inputBasename = path.basename(normalizedInput);
95
+ if (inputBasename === 'config.yaml' ||
96
+ inputBasename === 'config.yml' ||
97
+ isWorkspaceConfigFile(normalizedInput)) {
97
98
  throw new Error('If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config');
98
99
  }
99
100
  const { config } = readTestYamlFileAsJson(normalizedInput);
@@ -104,12 +105,18 @@ async function planSingleFile(normalizedInput, configFile) {
104
105
  flowOverrides[normalizedInput] = extractDeviceCloudOverrides(config);
105
106
  }
106
107
  let workspaceConfig;
107
- if (configFile) {
108
- const configFilePath = path.resolve(process.cwd(), configFile);
109
- if (!fs.existsSync(configFilePath)) {
110
- throw new Error(`Config file does not exist: ${configFilePath}`);
108
+ if (resolvedConfigFile) {
109
+ if (!fs.existsSync(resolvedConfigFile)) {
110
+ throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
111
+ }
112
+ workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn);
113
+ // Sequencing is resolved against a workspace's discovered flows, which a
114
+ // single-file input doesn't have — so executionOrder is ignored here. Say so
115
+ // rather than accepting a config that reads as if it applied (dcd-cli#110).
116
+ if (workspaceConfig.executionOrder?.flowsOrder.length) {
117
+ warn(`Warning: \`executionOrder\` in ${resolvedConfigFile} is ignored when a single flow file is passed.\n` +
118
+ `Pass the workspace folder instead so the named flows can be discovered and sequenced.`);
111
119
  }
112
- workspaceConfig = readYamlFileAsJson(configFilePath);
113
120
  }
114
121
  const checkedDependancies = await checkDependencies(normalizedInput);
115
122
  return {
@@ -126,11 +133,24 @@ async function planSingleFile(normalizedInput, configFile) {
126
133
  * @param workspaceConfig - Workspace configuration containing flow globs
127
134
  * @param normalizedInput - Normalized path to the workspace directory
128
135
  * @param unfilteredFlowFiles - List of all discovered flow files
129
- * @param configFile - Optional custom config file path
136
+ * @param resolvedConfigFile - Optional absolute path to a custom config file
130
137
  * @param excludeFlows - --exclude-flows patterns to re-apply to glob matches
131
138
  * @returns Filtered list of flow file paths matching the globs
132
139
  */
133
- async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile, excludeFlows) {
140
+ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, resolvedConfigFile, excludeFlows) {
141
+ // Both branches compare absolute paths, so `--config ./x.yml` and
142
+ // `--config /abs/x.yml` behave identically, and a same-named file in a
143
+ // sibling directory is never mistaken for the active config.
144
+ const activeConfig = resolvedConfigFile
145
+ ? path.normalize(resolvedConfigFile)
146
+ : undefined;
147
+ const isExcludedConfig = (absolutePath) => {
148
+ const base = path.basename(absolutePath);
149
+ if (base === 'config.yaml' || base === 'config.yml')
150
+ return true;
151
+ return (activeConfig !== undefined &&
152
+ path.normalize(absolutePath) === activeConfig);
153
+ };
134
154
  if (workspaceConfig.flows) {
135
155
  const globs = workspaceConfig.flows.map((g) => g);
136
156
  // fs.globSync lands in Node 22; the CLI's `engines.node` already requires it.
@@ -144,29 +164,17 @@ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFi
144
164
  return false;
145
165
  }
146
166
  });
167
+ // Resolve before filtering: glob matches are relative to normalizedInput,
168
+ // so comparing them against the config path only ever worked when the
169
+ // config happened to sit at the workspace root.
147
170
  const globbedFlowFiles = matchedFiles
148
- .filter((file) => {
149
- if (file === 'config.yaml' || file === 'config.yml')
150
- return false;
151
- if (configFile && file === path.basename(configFile))
152
- return false;
153
- if (!file.endsWith('.yaml') && !file.endsWith('.yml'))
154
- return false;
155
- const pathParts = file.split(path.sep);
156
- for (const part of pathParts) {
157
- if (part.endsWith('.app'))
158
- return false;
159
- }
160
- return true;
161
- })
162
- .map((file) => path.resolve(normalizedInput, file));
171
+ .map((file) => path.resolve(normalizedInput, file))
172
+ .filter((file) => !isExcludedConfig(file) && isFlowFile(file));
163
173
  // Re-globbing from disk bypasses the earlier --exclude-flows filter, so
164
174
  // re-apply it here or excluded flows sneak back in via `flows:` globs.
165
175
  return filterFlowFiles(globbedFlowFiles, excludeFlows);
166
176
  }
167
- return unfilteredFlowFiles.filter((file) => !file.endsWith('config.yaml') &&
168
- !file.endsWith('config.yml') &&
169
- (!configFile || !file.endsWith(configFile)));
177
+ return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file));
170
178
  }
171
179
  /**
172
180
  * Resolve sequential execution order from workspace config
@@ -223,14 +231,17 @@ function resolveSequentialFlows(workspaceConfig, pathsByName, debug) {
223
231
  * @throws Error if input path doesn't exist, no flows found, or dependencies missing
224
232
  */
225
233
  export async function plan(options) {
226
- const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, } = options;
234
+ const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, warn = (message) => console.warn(message), } = options;
227
235
  const normalizedInput = path.normalize(input);
228
236
  const flowMetadata = {};
237
+ const resolvedConfigFile = configFile
238
+ ? path.resolve(process.cwd(), configFile)
239
+ : undefined;
229
240
  if (!fs.existsSync(normalizedInput)) {
230
241
  throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`);
231
242
  }
232
243
  if (fs.lstatSync(normalizedInput).isFile()) {
233
- return planSingleFile(normalizedInput, configFile);
244
+ return planSingleFile(normalizedInput, warn, resolvedConfigFile);
234
245
  }
235
246
  let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
236
247
  if (unfilteredFlowFiles.length === 0) {
@@ -238,17 +249,30 @@ export async function plan(options) {
238
249
  }
239
250
  unfilteredFlowFiles = filterFlowFiles(unfilteredFlowFiles, excludeFlows);
240
251
  let workspaceConfig;
241
- if (configFile) {
242
- const configFilePath = path.resolve(process.cwd(), configFile);
243
- if (!fs.existsSync(configFilePath)) {
244
- throw new Error(`Config file does not exist: ${configFilePath}`);
252
+ if (resolvedConfigFile) {
253
+ if (!fs.existsSync(resolvedConfigFile)) {
254
+ throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
245
255
  }
246
- workspaceConfig = readYamlFileAsJson(configFilePath);
256
+ workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn);
247
257
  }
248
258
  else {
249
- workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
259
+ workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles, warn);
260
+ }
261
+ unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, resolvedConfigFile, excludeFlows);
262
+ // The exclusions above are filename-based: they only catch
263
+ // `config.yaml`/`config.yml` and the active --config target. Any other
264
+ // workspace config sharing the folder (a second CI workflow's, say) is still
265
+ // on the list and would be parsed as a flow, so drop config-shaped files by
266
+ // shape — see dcd-cli#99.
267
+ const configShapedFiles = new Set(unfilteredFlowFiles.filter((file) => isWorkspaceConfigFile(file)));
268
+ if (configShapedFiles.size > 0) {
269
+ if (debug) {
270
+ console.log(`[DEBUG] Skipping ${configShapedFiles.size} workspace config file(s): ${[
271
+ ...configShapedFiles,
272
+ ].join(', ')}`);
273
+ }
274
+ unfilteredFlowFiles = unfilteredFlowFiles.filter((file) => !configShapedFiles.has(file));
250
275
  }
251
- unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile, excludeFlows);
252
276
  if (unfilteredFlowFiles.length === 0) {
253
277
  const error = workspaceConfig.flows
254
278
  ? new Error(`Flow inclusion pattern(s) did not match any Flow files:\n${workspaceConfig.flows.join('\n')}`)
@@ -1,8 +1,38 @@
1
+ import { IWorkspaceConfig } from './workspace-config.schema.js';
1
2
  export declare function getFlowsToRunInSequence(paths: {
2
3
  [key: string]: string;
3
4
  }, flowOrder: string[], debug?: boolean): string[];
4
5
  export declare function isFlowFile(filePath: string): boolean;
6
+ /**
7
+ * True when a YAML file is a workspace config rather than a runnable flow.
8
+ *
9
+ * A flow is either `front matter --- steps` or a bare steps array; a
10
+ * single-document top-level map carrying workspace-config keys is neither, and
11
+ * left in the flow list it blows up processDependencies with "Expected an array
12
+ * of steps". Detection is by shape, not filename, so several named configs can
13
+ * coexist in one folder (dcd-cli#99). Requiring a recognised config key — not
14
+ * just "single document, top-level map" — keeps a flow that is merely *missing*
15
+ * its `---` separator loud rather than silently dropped.
16
+ *
17
+ * @param filePath - Path to the YAML file to classify
18
+ * @returns Whether the file is a workspace config rather than a flow
19
+ */
20
+ export declare function isWorkspaceConfigFile(filePath: string): boolean;
5
21
  export declare const readYamlFileAsJson: (filePath: string) => unknown;
22
+ /**
23
+ * Load and validate a workspace config file.
24
+ *
25
+ * The single chokepoint for reading a config: every caller gets a
26
+ * runtime-validated object instead of an unchecked `as IWorkspaceConfig` cast.
27
+ * Scalar-to-array coercion for `includeTags`/`excludeTags` lives in the schema,
28
+ * so `readYamlFileAsJson` stays a plain YAML read.
29
+ *
30
+ * @param filePath - Path to the config file
31
+ * @param warn - Sink for non-fatal problems (unrecognised keys)
32
+ * @returns The validated workspace config
33
+ * @throws Error if the file is unparseable or the config is invalid
34
+ */
35
+ export declare function loadWorkspaceConfig(filePath: string, warn: (message: string) => void): IWorkspaceConfig;
6
36
  export declare const readTestYamlFileAsJson: (filePath: string) => {
7
37
  config: Record<string, unknown>;
8
38
  testSteps: Record<string, unknown>[];
@@ -1,6 +1,7 @@
1
1
  import * as yaml from 'js-yaml';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import { parseWorkspaceConfig, WORKSPACE_CONFIG_KEYS, } from './workspace-config.schema.js';
4
5
  const commandsThatRequireFiles = new Set(['addMedia', 'runFlow', 'runScript']);
5
6
  export function getFlowsToRunInSequence(paths, flowOrder, debug = false) {
6
7
  if (flowOrder.length === 0) {
@@ -38,21 +39,43 @@ export function isFlowFile(filePath) {
38
39
  }
39
40
  return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
40
41
  }
42
+ /**
43
+ * True when a YAML file is a workspace config rather than a runnable flow.
44
+ *
45
+ * A flow is either `front matter --- steps` or a bare steps array; a
46
+ * single-document top-level map carrying workspace-config keys is neither, and
47
+ * left in the flow list it blows up processDependencies with "Expected an array
48
+ * of steps". Detection is by shape, not filename, so several named configs can
49
+ * coexist in one folder (dcd-cli#99). Requiring a recognised config key — not
50
+ * just "single document, top-level map" — keeps a flow that is merely *missing*
51
+ * its `---` separator loud rather than silently dropped.
52
+ *
53
+ * @param filePath - Path to the YAML file to classify
54
+ * @returns Whether the file is a workspace config rather than a flow
55
+ */
56
+ export function isWorkspaceConfigFile(filePath) {
57
+ let parsed;
58
+ try {
59
+ parsed = readTestYamlFileAsJson(filePath);
60
+ }
61
+ catch {
62
+ // Unparseable — leave it in the flow list so the existing error path reports it.
63
+ return false;
64
+ }
65
+ const { config, testSteps } = parsed;
66
+ if (config !== null)
67
+ return false; // has `---` front matter → flow
68
+ if (Array.isArray(testSteps))
69
+ return false; // bare steps array → flow
70
+ if (!testSteps || typeof testSteps !== 'object')
71
+ return false;
72
+ return Object.keys(testSteps).some((key) => WORKSPACE_CONFIG_KEYS.has(key));
73
+ }
41
74
  export const readYamlFileAsJson = (filePath) => {
42
75
  try {
43
76
  const normalizedPath = path.normalize(filePath);
44
77
  const yamlText = fs.readFileSync(normalizedPath, 'utf8');
45
- const result = yaml.load(yamlText);
46
- // Ensure includeTags and excludeTags are always arrays if present
47
- if (result && typeof result === 'object') {
48
- if ('includeTags' in result && !Array.isArray(result.includeTags)) {
49
- result.includeTags = result.includeTags ? [result.includeTags] : [];
50
- }
51
- if ('excludeTags' in result && !Array.isArray(result.excludeTags)) {
52
- result.excludeTags = result.excludeTags ? [result.excludeTags] : [];
53
- }
54
- }
55
- return result;
78
+ return yaml.load(yamlText);
56
79
  }
57
80
  catch (error) {
58
81
  throw new Error(`Error parsing YAML file ${filePath}: ${error}`, {
@@ -60,6 +83,22 @@ export const readYamlFileAsJson = (filePath) => {
60
83
  });
61
84
  }
62
85
  };
86
+ /**
87
+ * Load and validate a workspace config file.
88
+ *
89
+ * The single chokepoint for reading a config: every caller gets a
90
+ * runtime-validated object instead of an unchecked `as IWorkspaceConfig` cast.
91
+ * Scalar-to-array coercion for `includeTags`/`excludeTags` lives in the schema,
92
+ * so `readYamlFileAsJson` stays a plain YAML read.
93
+ *
94
+ * @param filePath - Path to the config file
95
+ * @param warn - Sink for non-fatal problems (unrecognised keys)
96
+ * @returns The validated workspace config
97
+ * @throws Error if the file is unparseable or the config is invalid
98
+ */
99
+ export function loadWorkspaceConfig(filePath, warn) {
100
+ return parseWorkspaceConfig(readYamlFileAsJson(filePath), { filePath, warn });
101
+ }
63
102
  export const readTestYamlFileAsJson = (filePath) => {
64
103
  try {
65
104
  const normalizedPath = path.normalize(filePath);
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * `looseObject`, not `object`: unknown keys are reported as warnings but must
4
+ * survive parsing, because the whole config is forwarded to the API as
5
+ * `fields.workspaceConfig` and stripping keys would silently alter that
6
+ * payload.
7
+ */
8
+ export declare const WorkspaceConfigSchema: z.ZodObject<{
9
+ excludeTags: z.ZodOptional<z.ZodNullable<z.ZodPreprocess<z.ZodArray<z.ZodString>>>>;
10
+ executionOrder: z.ZodOptional<z.ZodNullable<z.ZodObject<{
11
+ continueOnFailure: z.ZodDefault<z.ZodBoolean>;
12
+ flowsOrder: z.ZodArray<z.ZodString>;
13
+ }, z.core.$strip>>>;
14
+ flows: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
15
+ includeTags: z.ZodOptional<z.ZodNullable<z.ZodPreprocess<z.ZodArray<z.ZodString>>>>;
16
+ local: z.ZodOptional<z.ZodNullable<z.ZodObject<{
17
+ deterministicOrder: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
18
+ }, z.core.$loose>>>;
19
+ notifications: z.ZodOptional<z.ZodNullable<z.ZodObject<{
20
+ email: z.ZodOptional<z.ZodObject<{
21
+ enabled: z.ZodOptional<z.ZodBoolean>;
22
+ onSuccess: z.ZodOptional<z.ZodBoolean>;
23
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString>>;
24
+ }, z.core.$loose>>;
25
+ }, z.core.$loose>>>;
26
+ platform: z.ZodOptional<z.ZodNullable<z.ZodObject<{
27
+ android: z.ZodOptional<z.ZodObject<{
28
+ disableAnimations: z.ZodOptional<z.ZodBoolean>;
29
+ }, z.core.$loose>>;
30
+ ios: z.ZodOptional<z.ZodObject<{
31
+ disableAnimations: z.ZodOptional<z.ZodBoolean>;
32
+ }, z.core.$loose>>;
33
+ }, z.core.$loose>>>;
34
+ }, z.core.$loose>;
35
+ /** Workspace configuration from config.yaml */
36
+ export type IWorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
37
+ /**
38
+ * Top-level keys that only ever appear in a workspace config, derived from the
39
+ * schema so the two can't drift. Deliberately excludes keys Maestro also allows
40
+ * in flow front matter — appId, name, tags, env, onFlowStart, onFlowComplete,
41
+ * jsEngine — because this set also drives config-vs-flow detection
42
+ * (`isWorkspaceConfigFile`).
43
+ */
44
+ export declare const WORKSPACE_CONFIG_KEYS: ReadonlySet<string>;
45
+ /**
46
+ * Validate a parsed workspace config.
47
+ *
48
+ * Hard errors (thrown): the config isn't a map, or `executionOrder` is present
49
+ * but malformed. Both are unambiguous mistakes with no valid interpretation, so
50
+ * failing fast beats a warning that scrolls past in CI.
51
+ *
52
+ * Soft problems (warned): unknown top-level keys. These are preserved, not
53
+ * stripped, and warned about once.
54
+ *
55
+ * @param raw - The result of loading the YAML file
56
+ * @param options - Config file path (for messages) and a warning sink
57
+ * @returns The validated config, with defaults applied
58
+ * @throws Error if the config is not a map or `executionOrder` is malformed
59
+ */
60
+ export declare function parseWorkspaceConfig(raw: unknown, options: {
61
+ filePath: string;
62
+ warn: (message: string) => void;
63
+ }): IWorkspaceConfig;
@@ -0,0 +1,206 @@
1
+ import * as yaml from 'js-yaml';
2
+ import { z } from 'zod';
3
+ /**
4
+ * Runtime schema for a workspace `config.yaml`.
5
+ *
6
+ * This is the single source of truth for the config's shape — the TypeScript
7
+ * type is inferred from it (`IWorkspaceConfig`) rather than declared alongside
8
+ * it, so the compile-time and runtime views cannot drift. Before this existed
9
+ * the config was `yaml.load`ed and straight-cast, which meant an
10
+ * `executionOrder` written in the wrong shape was silently ignored and every
11
+ * flow ran in parallel (dcd-cli#110).
12
+ */
13
+ /**
14
+ * Tags may be written as a bare scalar (`includeTags: smoke`) or a list.
15
+ * Scalars are wrapped, and primitive members are coerced to strings so a
16
+ * YAML-numeric tag (`includeTags: [2]`) still compares against flow tags
17
+ * instead of silently matching nothing.
18
+ */
19
+ const tagList = z.preprocess((value) => {
20
+ if (value === null || value === undefined)
21
+ return value;
22
+ const members = Array.isArray(value) ? value : [value];
23
+ return members.map((member) => typeof member === 'number' || typeof member === 'boolean'
24
+ ? String(member)
25
+ : member);
26
+ }, z.array(z.string()));
27
+ /** Sequential execution configuration */
28
+ const ExecutionOrderSchema = z.object({
29
+ // Defaults to true: a failing flow does not stop the rest of the sequence.
30
+ // Declared here so the default lives in one place instead of being
31
+ // re-specified at each read site.
32
+ continueOnFailure: z.boolean().default(true),
33
+ flowsOrder: z.array(z.string()),
34
+ });
35
+ /**
36
+ * `looseObject`, not `object`: unknown keys are reported as warnings but must
37
+ * survive parsing, because the whole config is forwarded to the API as
38
+ * `fields.workspaceConfig` and stripping keys would silently alter that
39
+ * payload.
40
+ */
41
+ export const WorkspaceConfigSchema = z.looseObject({
42
+ excludeTags: tagList.nullish(),
43
+ executionOrder: ExecutionOrderSchema.nullish(),
44
+ flows: z.array(z.string()).nullish(),
45
+ includeTags: tagList.nullish(),
46
+ local: z
47
+ .looseObject({ deterministicOrder: z.boolean().nullish() })
48
+ .nullish(),
49
+ notifications: z
50
+ .looseObject({
51
+ email: z
52
+ .looseObject({
53
+ enabled: z.boolean().optional(),
54
+ onSuccess: z.boolean().optional(),
55
+ recipients: z.array(z.string()).optional(),
56
+ })
57
+ .optional(),
58
+ })
59
+ .nullish(),
60
+ platform: z
61
+ .looseObject({
62
+ android: z
63
+ .looseObject({ disableAnimations: z.boolean().optional() })
64
+ .optional(),
65
+ ios: z
66
+ .looseObject({ disableAnimations: z.boolean().optional() })
67
+ .optional(),
68
+ })
69
+ .nullish(),
70
+ });
71
+ /**
72
+ * Top-level keys that only ever appear in a workspace config, derived from the
73
+ * schema so the two can't drift. Deliberately excludes keys Maestro also allows
74
+ * in flow front matter — appId, name, tags, env, onFlowStart, onFlowComplete,
75
+ * jsEngine — because this set also drives config-vs-flow detection
76
+ * (`isWorkspaceConfigFile`).
77
+ */
78
+ export const WORKSPACE_CONFIG_KEYS = new Set(Object.keys(WorkspaceConfigSchema.shape));
79
+ /**
80
+ * Near-misses that aren't just a casing slip on a real key. Keyed lowercase.
81
+ */
82
+ const KEY_ALIASES = {
83
+ continueonfailure: 'executionOrder.continueOnFailure',
84
+ excludetag: 'excludeTags',
85
+ floworder: 'executionOrder.flowsOrder',
86
+ flowsorder: 'executionOrder.flowsOrder',
87
+ includetag: 'includeTags',
88
+ tags: 'includeTags / excludeTags',
89
+ };
90
+ /**
91
+ * Suggest the key the author probably meant.
92
+ * @param unknownKey - Unrecognised top-level key from the config
93
+ * @returns The suggested key name, or undefined if there's no close match
94
+ */
95
+ function suggestKey(unknownKey) {
96
+ const lowered = unknownKey.toLowerCase();
97
+ const casingSlip = [...WORKSPACE_CONFIG_KEYS].find((key) => key.toLowerCase() === lowered);
98
+ return casingSlip ?? KEY_ALIASES[lowered];
99
+ }
100
+ /**
101
+ * Describe how an `executionOrder` value is malformed, in prose.
102
+ *
103
+ * Split out so the most likely mistake — a bare list of flow names — gets a
104
+ * message showing the expected shape rather than a generic schema dump.
105
+ *
106
+ * @param value - The raw `executionOrder` value from the config
107
+ * @returns A description of the problem, or undefined if the shape is valid
108
+ */
109
+ function describeExecutionOrderShape(value) {
110
+ if (value === null || value === undefined)
111
+ return undefined;
112
+ if (Array.isArray(value))
113
+ return 'a bare list of flow names';
114
+ if (typeof value !== 'object')
115
+ return `a ${typeof value} value`;
116
+ const { flowsOrder } = value;
117
+ if (flowsOrder === undefined)
118
+ return 'a map with no `flowsOrder` key';
119
+ if (!Array.isArray(flowsOrder)) {
120
+ return 'a map whose `flowsOrder` is not a list';
121
+ }
122
+ return undefined;
123
+ }
124
+ /** Indent a YAML fragment so it reads as a block inside an error message. */
125
+ function indentYaml(value) {
126
+ return yaml
127
+ .dump(value, { lineWidth: -1 })
128
+ .trimEnd()
129
+ .split('\n')
130
+ .map((line) => ` ${line}`)
131
+ .join('\n');
132
+ }
133
+ /**
134
+ * Build the error for a malformed `executionOrder`, showing what was found
135
+ * next to what was expected.
136
+ * @param filePath - Path to the config file, for the message
137
+ * @param problem - Prose description from describeExecutionOrderShape
138
+ * @param found - The raw `executionOrder` value that failed
139
+ * @returns The error to throw
140
+ */
141
+ function executionOrderError(filePath, problem, found) {
142
+ return new Error(`Invalid \`executionOrder\` in ${filePath}\n\n` +
143
+ `\`executionOrder\` must be a map containing a \`flowsOrder\` list, but it is ${problem}.\n\n` +
144
+ `Found:\n${indentYaml({ executionOrder: found })}\n\n` +
145
+ `Expected:\n${indentYaml({
146
+ executionOrder: {
147
+ continueOnFailure: true,
148
+ flowsOrder: ['first-flow', 'second-flow'],
149
+ },
150
+ })}\n\n` +
151
+ `Without \`flowsOrder\` the flows are not sequenced — they all run in parallel.`);
152
+ }
153
+ /**
154
+ * Validate a parsed workspace config.
155
+ *
156
+ * Hard errors (thrown): the config isn't a map, or `executionOrder` is present
157
+ * but malformed. Both are unambiguous mistakes with no valid interpretation, so
158
+ * failing fast beats a warning that scrolls past in CI.
159
+ *
160
+ * Soft problems (warned): unknown top-level keys. These are preserved, not
161
+ * stripped, and warned about once.
162
+ *
163
+ * @param raw - The result of loading the YAML file
164
+ * @param options - Config file path (for messages) and a warning sink
165
+ * @returns The validated config, with defaults applied
166
+ * @throws Error if the config is not a map or `executionOrder` is malformed
167
+ */
168
+ export function parseWorkspaceConfig(raw, options) {
169
+ const { filePath, warn } = options;
170
+ if (raw === null || raw === undefined)
171
+ return {};
172
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
173
+ throw new Error(`Invalid workspace config in ${filePath}\n\n` +
174
+ `Expected a map of configuration keys (${[...WORKSPACE_CONFIG_KEYS].join(', ')}), but found ${Array.isArray(raw) ? 'a list' : `a ${typeof raw} value`}.`);
175
+ }
176
+ const rawConfig = raw;
177
+ // Checked ahead of the schema so this specific mistake gets a targeted
178
+ // message; the schema would otherwise report "expected object, received array".
179
+ const executionOrderProblem = describeExecutionOrderShape(rawConfig.executionOrder);
180
+ if (executionOrderProblem) {
181
+ throw executionOrderError(filePath, executionOrderProblem, rawConfig.executionOrder);
182
+ }
183
+ const unknownKeys = Object.keys(rawConfig).filter((key) => !WORKSPACE_CONFIG_KEYS.has(key));
184
+ if (unknownKeys.length > 0) {
185
+ const lines = unknownKeys.map((key) => {
186
+ const suggestion = suggestKey(key);
187
+ return suggestion
188
+ ? ` ${key} — did you mean ${suggestion}?`
189
+ : ` ${key}`;
190
+ });
191
+ warn(`Warning: unrecognised key(s) in ${filePath} — these are ignored:\n` +
192
+ `${lines.join('\n')}\n\n` +
193
+ `Supported keys: ${[...WORKSPACE_CONFIG_KEYS].join(', ')}`);
194
+ }
195
+ const result = WorkspaceConfigSchema.safeParse(rawConfig);
196
+ if (!result.success) {
197
+ const issues = result.error.issues
198
+ .map((issue) => {
199
+ const location = issue.path.length > 0 ? issue.path.join('.') : '(root)';
200
+ return ` ${location}: ${issue.message}`;
201
+ })
202
+ .join('\n');
203
+ throw new Error(`Invalid workspace config in ${filePath}\n\n${issues}`);
204
+ }
205
+ return result.data;
206
+ }
package/package.json CHANGED
@@ -31,7 +31,7 @@
31
31
  "chai": "^6.2.2",
32
32
  "eslint": "^10.5.0",
33
33
  "eslint-config-prettier": "^10.1.8",
34
- "eslint-plugin-unicorn": "^72.0.0",
34
+ "eslint-plugin-unicorn": "^73.0.0",
35
35
  "husky": "^9.1.7",
36
36
  "mocha": "^11.7.6",
37
37
  "prettier": "^3.8.4",
@@ -59,7 +59,7 @@
59
59
  "type": "git",
60
60
  "url": "https://devicecloud.dev"
61
61
  },
62
- "version": "5.3.0",
62
+ "version": "5.3.1-beta.2",
63
63
  "bugs": {
64
64
  "url": "https://discord.gg/gm3mJwcNw8"
65
65
  },