@devicecloud.dev/dcd 5.3.1-beta.1 → 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 +18 -12
- package/dist/services/execution-plan.utils.d.ts +15 -0
- package/dist/services/execution-plan.utils.js +18 -26
- package/dist/services/workspace-config.schema.d.ts +63 -0
- package/dist/services/workspace-config.schema.js +206 -0
- package/package.json +1 -1
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, isWorkspaceConfigFile, 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,10 +86,11 @@ 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
|
|
89
|
+
* @param warn - Sink for non-fatal config problems
|
|
91
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, resolvedConfigFile) {
|
|
93
|
+
async function planSingleFile(normalizedInput, warn, resolvedConfigFile) {
|
|
95
94
|
const inputBasename = path.basename(normalizedInput);
|
|
96
95
|
if (inputBasename === 'config.yaml' ||
|
|
97
96
|
inputBasename === 'config.yml' ||
|
|
@@ -110,7 +109,14 @@ async function planSingleFile(normalizedInput, resolvedConfigFile) {
|
|
|
110
109
|
if (!fs.existsSync(resolvedConfigFile)) {
|
|
111
110
|
throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
|
|
112
111
|
}
|
|
113
|
-
workspaceConfig =
|
|
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.`);
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
const checkedDependancies = await checkDependencies(normalizedInput);
|
|
116
122
|
return {
|
|
@@ -225,7 +231,7 @@ function resolveSequentialFlows(workspaceConfig, pathsByName, debug) {
|
|
|
225
231
|
* @throws Error if input path doesn't exist, no flows found, or dependencies missing
|
|
226
232
|
*/
|
|
227
233
|
export async function plan(options) {
|
|
228
|
-
const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, } = options;
|
|
234
|
+
const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, warn = (message) => console.warn(message), } = options;
|
|
229
235
|
const normalizedInput = path.normalize(input);
|
|
230
236
|
const flowMetadata = {};
|
|
231
237
|
const resolvedConfigFile = configFile
|
|
@@ -235,7 +241,7 @@ export async function plan(options) {
|
|
|
235
241
|
throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`);
|
|
236
242
|
}
|
|
237
243
|
if (fs.lstatSync(normalizedInput).isFile()) {
|
|
238
|
-
return planSingleFile(normalizedInput, resolvedConfigFile);
|
|
244
|
+
return planSingleFile(normalizedInput, warn, resolvedConfigFile);
|
|
239
245
|
}
|
|
240
246
|
let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
|
|
241
247
|
if (unfilteredFlowFiles.length === 0) {
|
|
@@ -247,10 +253,10 @@ export async function plan(options) {
|
|
|
247
253
|
if (!fs.existsSync(resolvedConfigFile)) {
|
|
248
254
|
throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
|
|
249
255
|
}
|
|
250
|
-
workspaceConfig =
|
|
256
|
+
workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn);
|
|
251
257
|
}
|
|
252
258
|
else {
|
|
253
|
-
workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
|
|
259
|
+
workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles, warn);
|
|
254
260
|
}
|
|
255
261
|
unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, resolvedConfigFile, excludeFlows);
|
|
256
262
|
// The exclusions above are filename-based: they only catch
|
|
@@ -1,3 +1,4 @@
|
|
|
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[];
|
|
@@ -18,6 +19,20 @@ export declare function isFlowFile(filePath: string): boolean;
|
|
|
18
19
|
*/
|
|
19
20
|
export declare function isWorkspaceConfigFile(filePath: string): boolean;
|
|
20
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;
|
|
21
36
|
export declare const readTestYamlFileAsJson: (filePath: string) => {
|
|
22
37
|
config: Record<string, unknown>;
|
|
23
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,6 @@ export function isFlowFile(filePath) {
|
|
|
38
39
|
}
|
|
39
40
|
return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
|
|
40
41
|
}
|
|
41
|
-
/**
|
|
42
|
-
* Top-level keys that only ever appear in a workspace config (see
|
|
43
|
-
* IWorkspaceConfig in execution-plan.service.ts). Deliberately excludes keys
|
|
44
|
-
* Maestro also allows in flow front matter — appId, name, tags, env,
|
|
45
|
-
* onFlowStart, onFlowComplete, jsEngine.
|
|
46
|
-
*/
|
|
47
|
-
const WORKSPACE_CONFIG_KEYS = new Set([
|
|
48
|
-
'excludeTags',
|
|
49
|
-
'executionOrder',
|
|
50
|
-
'flows',
|
|
51
|
-
'includeTags',
|
|
52
|
-
'local',
|
|
53
|
-
'notifications',
|
|
54
|
-
'platform',
|
|
55
|
-
]);
|
|
56
42
|
/**
|
|
57
43
|
* True when a YAML file is a workspace config rather than a runnable flow.
|
|
58
44
|
*
|
|
@@ -89,17 +75,7 @@ export const readYamlFileAsJson = (filePath) => {
|
|
|
89
75
|
try {
|
|
90
76
|
const normalizedPath = path.normalize(filePath);
|
|
91
77
|
const yamlText = fs.readFileSync(normalizedPath, 'utf8');
|
|
92
|
-
|
|
93
|
-
// Ensure includeTags and excludeTags are always arrays if present
|
|
94
|
-
if (result && typeof result === 'object') {
|
|
95
|
-
if ('includeTags' in result && !Array.isArray(result.includeTags)) {
|
|
96
|
-
result.includeTags = result.includeTags ? [result.includeTags] : [];
|
|
97
|
-
}
|
|
98
|
-
if ('excludeTags' in result && !Array.isArray(result.excludeTags)) {
|
|
99
|
-
result.excludeTags = result.excludeTags ? [result.excludeTags] : [];
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return result;
|
|
78
|
+
return yaml.load(yamlText);
|
|
103
79
|
}
|
|
104
80
|
catch (error) {
|
|
105
81
|
throw new Error(`Error parsing YAML file ${filePath}: ${error}`, {
|
|
@@ -107,6 +83,22 @@ export const readYamlFileAsJson = (filePath) => {
|
|
|
107
83
|
});
|
|
108
84
|
}
|
|
109
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
|
+
}
|
|
110
102
|
export const readTestYamlFileAsJson = (filePath) => {
|
|
111
103
|
try {
|
|
112
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
|
+
}
|