@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.
- package/dist/commands/cloud.js +3 -0
- package/dist/mcp/tools/run-cloud-test.js +2 -0
- package/dist/services/execution-plan.service.d.ts +7 -34
- package/dist/services/execution-plan.service.js +68 -44
- package/dist/services/execution-plan.utils.d.ts +30 -0
- package/dist/services/execution-plan.utils.js +50 -11
- package/dist/services/workspace-config.schema.d.ts +63 -0
- package/dist/services/workspace-config.schema.js +206 -0
- package/package.json +2 -2
package/dist/commands/cloud.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
95
|
-
|
|
96
|
-
|
|
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 (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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,
|
|
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
|
-
.
|
|
149
|
-
|
|
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
|
|
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,
|
|
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 (
|
|
242
|
-
|
|
243
|
-
|
|
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 =
|
|
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
|
-
|
|
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": "^
|
|
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.
|
|
62
|
+
"version": "5.3.1-beta.2",
|
|
63
63
|
"bugs": {
|
|
64
64
|
"url": "https://discord.gg/gm3mJwcNw8"
|
|
65
65
|
},
|