@devicecloud.dev/dcd 5.5.0 → 5.6.0-beta.3
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.d.ts +5 -0
- package/dist/commands/cloud.js +11 -2
- package/dist/config/flags/execution.flags.d.ts +5 -0
- package/dist/config/flags/execution.flags.js +5 -0
- package/dist/constants.d.ts +5 -0
- package/dist/mcp/tools/run-cloud-test.js +1 -1
- package/dist/services/execution-plan.service.d.ts +6 -0
- package/dist/services/execution-plan.service.js +98 -2
- package/dist/services/execution-plan.utils.js +69 -6
- package/dist/services/flow-paths.d.ts +9 -1
- package/dist/services/flow-paths.js +14 -2
- package/dist/services/results-polling.service.d.ts +21 -1
- package/dist/services/results-polling.service.js +78 -4
- package/dist/services/test-submission.service.d.ts +7 -0
- package/dist/services/test-submission.service.js +19 -2
- package/dist/services/workspace-config.schema.d.ts +1 -0
- package/dist/services/workspace-config.schema.js +6 -0
- package/package.json +51 -11
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -98,6 +98,11 @@ export declare const cloudCommand: import("citty").CommandDef<{
|
|
|
98
98
|
readonly type: "string";
|
|
99
99
|
readonly description: "Pull request URL for this run (stored as gh_pr_url metadata)";
|
|
100
100
|
};
|
|
101
|
+
readonly 'cancel-previous': {
|
|
102
|
+
readonly type: "boolean";
|
|
103
|
+
readonly default: false;
|
|
104
|
+
readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
|
|
105
|
+
};
|
|
101
106
|
readonly config: {
|
|
102
107
|
readonly type: "string";
|
|
103
108
|
readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
|
package/dist/commands/cloud.js
CHANGED
|
@@ -132,6 +132,7 @@ export const cloudCommand = defineCommand({
|
|
|
132
132
|
const excludeFlows = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-flows']));
|
|
133
133
|
const excludeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-tags']));
|
|
134
134
|
let flows = args.flows;
|
|
135
|
+
const cancelPrevious = Boolean(args['cancel-previous']);
|
|
135
136
|
const googlePlay = Boolean(args['google-play']);
|
|
136
137
|
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
|
|
137
138
|
// Single opt-in for client-side envelope encryption of every sensitive
|
|
@@ -401,15 +402,22 @@ export const cloudCommand = defineCommand({
|
|
|
401
402
|
}
|
|
402
403
|
throw error;
|
|
403
404
|
}
|
|
404
|
-
const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, } = executionPlan;
|
|
405
|
+
const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, includedFiles, referencedFiles, sequence, } = executionPlan;
|
|
405
406
|
if (debug) {
|
|
406
407
|
out(`[DEBUG] All include tags: ${allIncludeTags?.join(', ') || 'none'}`);
|
|
407
408
|
out(`[DEBUG] All exclude tags: ${allExcludeTags?.join(', ') || 'none'}`);
|
|
408
409
|
out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`);
|
|
409
410
|
}
|
|
410
|
-
const commonRoot = computeCommonRoot(testFileNames, referencedFiles);
|
|
411
|
+
const commonRoot = computeCommonRoot(testFileNames, referencedFiles, includedFiles);
|
|
411
412
|
if (debug) {
|
|
412
413
|
out(`[DEBUG] Common root directory: ${commonRoot}`);
|
|
414
|
+
// `includedPaths` files sitting beside the flows tree rather than
|
|
415
|
+
// inside it raise the common root, so every server-side flow key gains
|
|
416
|
+
// a leading segment. Harmless but visible in the console, so say it.
|
|
417
|
+
const rootWithoutIncludes = computeCommonRoot(testFileNames, referencedFiles);
|
|
418
|
+
if (includedFiles.length > 0 && rootWithoutIncludes !== commonRoot) {
|
|
419
|
+
out(`[DEBUG] \`includedPaths\` raised the common root from ${rootWithoutIncludes} to ${commonRoot} — flow paths gain a leading segment`);
|
|
420
|
+
}
|
|
413
421
|
}
|
|
414
422
|
const testMetadataMap = buildTestMetadataMap(flowMetadata, commonRoot);
|
|
415
423
|
if (debug) {
|
|
@@ -549,6 +557,7 @@ export const cloudCommand = defineCommand({
|
|
|
549
557
|
androidNoSnapshot,
|
|
550
558
|
apiUrl,
|
|
551
559
|
appBinaryId: finalBinaryId,
|
|
560
|
+
cancelPrevious,
|
|
552
561
|
cliVersion,
|
|
553
562
|
commonRoot,
|
|
554
563
|
continueOnFailure,
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Test execution and flow management flags
|
|
3
3
|
*/
|
|
4
4
|
export declare const executionFlags: {
|
|
5
|
+
readonly 'cancel-previous': {
|
|
6
|
+
readonly type: "boolean";
|
|
7
|
+
readonly default: false;
|
|
8
|
+
readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
|
|
9
|
+
};
|
|
5
10
|
readonly config: {
|
|
6
11
|
readonly type: "string";
|
|
7
12
|
readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Test execution and flow management flags
|
|
3
3
|
*/
|
|
4
4
|
export const executionFlags = {
|
|
5
|
+
'cancel-previous': {
|
|
6
|
+
type: 'boolean',
|
|
7
|
+
default: false,
|
|
8
|
+
description: 'Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.',
|
|
9
|
+
},
|
|
5
10
|
config: {
|
|
6
11
|
type: 'string',
|
|
7
12
|
description: 'Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.',
|
package/dist/constants.d.ts
CHANGED
|
@@ -84,6 +84,11 @@ export declare const flags: {
|
|
|
84
84
|
readonly type: "string";
|
|
85
85
|
readonly description: "Pull request URL for this run (stored as gh_pr_url metadata)";
|
|
86
86
|
};
|
|
87
|
+
readonly 'cancel-previous': {
|
|
88
|
+
readonly type: "boolean";
|
|
89
|
+
readonly default: false;
|
|
90
|
+
readonly description: "Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.";
|
|
91
|
+
};
|
|
87
92
|
readonly config: {
|
|
88
93
|
readonly type: "string";
|
|
89
94
|
readonly description: "Path to custom config.yaml file. If not provided, defaults to config.yaml in root flows folders.";
|
|
@@ -120,7 +120,7 @@ export function registerRunCloudTest(server) {
|
|
|
120
120
|
// stdout is the JSON-RPC channel — config warnings must go to stderr.
|
|
121
121
|
warn: logStderr,
|
|
122
122
|
});
|
|
123
|
-
const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles);
|
|
123
|
+
const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles, executionPlan.includedFiles);
|
|
124
124
|
const testMetadataMap = buildTestMetadataMap(executionPlan.flowMetadata, commonRoot);
|
|
125
125
|
if (args.dryRun) {
|
|
126
126
|
return jsonResult({
|
|
@@ -21,6 +21,12 @@ export interface IExecutionPlan {
|
|
|
21
21
|
flowMetadata: Record<string, Record<string, unknown>>;
|
|
22
22
|
flowOverrides: Record<string, Record<string, unknown>>;
|
|
23
23
|
flowsToRun: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Extra files pulled in by `config.yaml`'s `includedPaths`. Kept separate
|
|
26
|
+
* from `referencedFiles` (which is derived from flow commands) so the zip
|
|
27
|
+
* manifest and the common-root calculation can tell the two apart.
|
|
28
|
+
*/
|
|
29
|
+
includedFiles: string[];
|
|
24
30
|
referencedFiles: string[];
|
|
25
31
|
sequence?: IFlowSequence | null;
|
|
26
32
|
totalFlowFiles: number;
|
|
@@ -90,7 +90,7 @@ function extractDeviceCloudOverrides(config) {
|
|
|
90
90
|
* @param resolvedConfigFile - Optional absolute path to a custom config file
|
|
91
91
|
* @returns Execution plan for the single file with dependencies
|
|
92
92
|
*/
|
|
93
|
-
async function planSingleFile(normalizedInput, warn, resolvedConfigFile) {
|
|
93
|
+
async function planSingleFile(normalizedInput, warn, resolvedConfigFile, debug = false) {
|
|
94
94
|
const inputBasename = path.basename(normalizedInput);
|
|
95
95
|
if (inputBasename === 'config.yaml' ||
|
|
96
96
|
inputBasename === 'config.yml' ||
|
|
@@ -118,11 +118,18 @@ async function planSingleFile(normalizedInput, warn, resolvedConfigFile) {
|
|
|
118
118
|
`Pass the workspace folder instead so the named flows can be discovered and sequenced.`);
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
// A single-file input has no workspace directory, so `includedPaths` (which
|
|
122
|
+
// only reaches here via --config) anchors on the flow file's own directory —
|
|
123
|
+
// the same place Maestro resolves an assertScreenshot baseline from.
|
|
124
|
+
const includedFiles = workspaceConfig
|
|
125
|
+
? resolveIncludedPaths(workspaceConfig, path.dirname(normalizedInput), warn, debug)
|
|
126
|
+
: [];
|
|
121
127
|
const checkedDependancies = await checkDependencies(normalizedInput);
|
|
122
128
|
return {
|
|
123
129
|
flowMetadata,
|
|
124
130
|
flowOverrides,
|
|
125
131
|
flowsToRun: [normalizedInput],
|
|
132
|
+
includedFiles,
|
|
126
133
|
referencedFiles: [...new Set(checkedDependancies)],
|
|
127
134
|
totalFlowFiles: 1,
|
|
128
135
|
workspaceConfig,
|
|
@@ -176,6 +183,94 @@ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFi
|
|
|
176
183
|
}
|
|
177
184
|
return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file));
|
|
178
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* The whole archive is buffered in memory by `compressFilesFromRelativePath`,
|
|
188
|
+
* so an unbounded `**` glob is a real footgun. These are warn thresholds, not
|
|
189
|
+
* hard limits — a legitimately large baseline set should still upload.
|
|
190
|
+
*/
|
|
191
|
+
const INCLUDED_PATHS_FILE_WARN_THRESHOLD = 200;
|
|
192
|
+
const INCLUDED_PATHS_BYTES_WARN_THRESHOLD = 50 * 1024 * 1024;
|
|
193
|
+
/**
|
|
194
|
+
* Resolve `config.yaml`'s `includedPaths` globs into absolute file paths.
|
|
195
|
+
*
|
|
196
|
+
* This is the general-purpose escape hatch for shipping files the flow
|
|
197
|
+
* commands don't reference: `assertScreenshot` baselines above all, but also
|
|
198
|
+
* fixtures, test data and certificates. Only `addMedia` / `runFlow` /
|
|
199
|
+
* `runScript` arguments are discovered by walking the flows, so without this
|
|
200
|
+
* key such files are silently absent from the uploaded zip.
|
|
201
|
+
*
|
|
202
|
+
* Glob semantics deliberately mirror `flows:` (`applyFlowGlobs`): patterns
|
|
203
|
+
* resolve against the workspace root, never the config file's directory, so
|
|
204
|
+
* `--config ci/workspace.yaml` behaves identically to an auto-detected config.
|
|
205
|
+
*
|
|
206
|
+
* @param workspaceConfig - Validated workspace config
|
|
207
|
+
* @param normalizedInput - Normalized path to the workspace directory
|
|
208
|
+
* @param warn - Sink for non-fatal problems
|
|
209
|
+
* @param debug - Whether to emit debug logging
|
|
210
|
+
* @returns Absolute paths of every matched file, deduped and sorted
|
|
211
|
+
* @throws Error if a pattern escapes the workspace root
|
|
212
|
+
*/
|
|
213
|
+
function resolveIncludedPaths(workspaceConfig, normalizedInput, warn, debug = false) {
|
|
214
|
+
const patterns = workspaceConfig.includedPaths;
|
|
215
|
+
if (!patterns || patterns.length === 0)
|
|
216
|
+
return [];
|
|
217
|
+
const workspaceRoot = path.resolve(normalizedInput);
|
|
218
|
+
const resolved = new Set();
|
|
219
|
+
const unmatched = [];
|
|
220
|
+
for (const pattern of patterns) {
|
|
221
|
+
// fs.globSync lands in Node 22; the CLI's `engines.node` already requires
|
|
222
|
+
// it. No `nodir` option — directories are stripped by the stat check below.
|
|
223
|
+
const matches = fs.globSync(pattern, { cwd: normalizedInput });
|
|
224
|
+
let matchedFile = false;
|
|
225
|
+
for (const match of matches) {
|
|
226
|
+
const absolute = path.resolve(normalizedInput, match);
|
|
227
|
+
// Containment guard: `flows:` has none because its matches are only ever
|
|
228
|
+
// parsed as YAML, but this key ships arbitrary bytes to a remote runner,
|
|
229
|
+
// so a `../../../` climb must not silently leave the workspace.
|
|
230
|
+
const relative = path.relative(workspaceRoot, absolute);
|
|
231
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
232
|
+
throw new Error(`\`includedPaths\` pattern "${pattern}" resolves outside the workspace: ${absolute}\n\n` +
|
|
233
|
+
`Included paths must stay within ${workspaceRoot}.`);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
if (!fs.statSync(absolute).isFile())
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
matchedFile = true;
|
|
243
|
+
resolved.add(absolute);
|
|
244
|
+
}
|
|
245
|
+
if (!matchedFile)
|
|
246
|
+
unmatched.push(pattern);
|
|
247
|
+
}
|
|
248
|
+
if (unmatched.length > 0) {
|
|
249
|
+
warn(`Warning: \`includedPaths\` pattern(s) in config matched no files:\n` +
|
|
250
|
+
`${unmatched.map((pattern) => ` ${pattern}`).join('\n')}\n\n` +
|
|
251
|
+
`Patterns are resolved relative to ${workspaceRoot}.`);
|
|
252
|
+
}
|
|
253
|
+
const files = [...resolved].sort((a, b) => a.localeCompare(b));
|
|
254
|
+
let totalBytes = 0;
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
try {
|
|
257
|
+
totalBytes += fs.statSync(file).size;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Raced away between glob and stat; the zip step reports it properly.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (files.length > INCLUDED_PATHS_FILE_WARN_THRESHOLD ||
|
|
264
|
+
totalBytes > INCLUDED_PATHS_BYTES_WARN_THRESHOLD) {
|
|
265
|
+
warn(`Warning: \`includedPaths\` matched ${files.length} file(s) totalling ` +
|
|
266
|
+
`${Math.round(totalBytes / (1024 * 1024))} MB. The flow archive is built in ` +
|
|
267
|
+
`memory, so consider narrowing the patterns.`);
|
|
268
|
+
}
|
|
269
|
+
if (debug) {
|
|
270
|
+
console.log(`[DEBUG] includedPaths matched ${files.length} file(s):\n${files.join('\n')}`);
|
|
271
|
+
}
|
|
272
|
+
return files;
|
|
273
|
+
}
|
|
179
274
|
/**
|
|
180
275
|
* Resolve sequential execution order from workspace config
|
|
181
276
|
* @param workspaceConfig - Workspace configuration with executionOrder
|
|
@@ -241,7 +336,7 @@ export async function plan(options) {
|
|
|
241
336
|
throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`);
|
|
242
337
|
}
|
|
243
338
|
if (fs.lstatSync(normalizedInput).isFile()) {
|
|
244
|
-
return planSingleFile(normalizedInput, warn, resolvedConfigFile);
|
|
339
|
+
return planSingleFile(normalizedInput, warn, resolvedConfigFile, debug);
|
|
245
340
|
}
|
|
246
341
|
let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
|
|
247
342
|
if (unfilteredFlowFiles.length === 0) {
|
|
@@ -325,6 +420,7 @@ export async function plan(options) {
|
|
|
325
420
|
flowMetadata,
|
|
326
421
|
flowOverrides,
|
|
327
422
|
flowsToRun: normalFlows,
|
|
423
|
+
includedFiles: resolveIncludedPaths(workspaceConfig, normalizedInput, warn, debug),
|
|
328
424
|
referencedFiles: [...new Set(allFiles)],
|
|
329
425
|
sequence: {
|
|
330
426
|
continueOnFailure: workspaceConfig.executionOrder?.continueOnFailure,
|
|
@@ -2,7 +2,50 @@ import * as yaml from 'js-yaml';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { parseWorkspaceConfig, WORKSPACE_CONFIG_KEYS, } from './workspace-config.schema.js';
|
|
5
|
-
const commandsThatRequireFiles = new Set([
|
|
5
|
+
const commandsThatRequireFiles = new Set([
|
|
6
|
+
'addMedia',
|
|
7
|
+
'assertScreenshot',
|
|
8
|
+
'runFlow',
|
|
9
|
+
'runScript',
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* Commands whose file references are best-effort rather than mandatory.
|
|
13
|
+
*
|
|
14
|
+
* `assertScreenshot` baselines are legitimately absent on a first run, and
|
|
15
|
+
* Maestro's own "Screenshot file not found — searched in: …" error is more
|
|
16
|
+
* useful than ours, so a missing baseline must not abort the upload the way a
|
|
17
|
+
* missing `addMedia` file does.
|
|
18
|
+
*/
|
|
19
|
+
const commandsWithOptionalFiles = new Set(['assertScreenshot']);
|
|
20
|
+
/**
|
|
21
|
+
* Extensions Maestro's `normalizeScreenshotPath` recognises; anything else
|
|
22
|
+
* gets `.png` appended, so `assertScreenshot: home` means `home.png`.
|
|
23
|
+
*/
|
|
24
|
+
const SCREENSHOT_EXTENSIONS = new Set([
|
|
25
|
+
'.bmp',
|
|
26
|
+
'.gif',
|
|
27
|
+
'.heic',
|
|
28
|
+
'.heif',
|
|
29
|
+
'.jpeg',
|
|
30
|
+
'.jpg',
|
|
31
|
+
'.png',
|
|
32
|
+
'.tiff',
|
|
33
|
+
'.wbmp',
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Mirror Maestro's `Orchestra.normalizeScreenshotPath`: a screenshot path with
|
|
37
|
+
* no image extension gets `.png`. Without this, `assertScreenshot: home` looks
|
|
38
|
+
* like a missing file here while resolving fine on the device.
|
|
39
|
+
*
|
|
40
|
+
* @param relativePath - The path as written in the flow
|
|
41
|
+
* @returns The path with an image extension guaranteed
|
|
42
|
+
*/
|
|
43
|
+
function normalizeScreenshotPath(relativePath) {
|
|
44
|
+
const extension = path.extname(relativePath).toLowerCase();
|
|
45
|
+
return SCREENSHOT_EXTENSIONS.has(extension)
|
|
46
|
+
? relativePath
|
|
47
|
+
: `${relativePath}.png`;
|
|
48
|
+
}
|
|
6
49
|
export function getFlowsToRunInSequence(paths, flowOrder, debug = false) {
|
|
7
50
|
if (flowOrder.length === 0) {
|
|
8
51
|
if (debug) {
|
|
@@ -145,12 +188,27 @@ export const checkIfFilesExistInWorkspace = (commandName, command, absoluteFileP
|
|
|
145
188
|
const errors = [];
|
|
146
189
|
const files = [];
|
|
147
190
|
const directory = path.dirname(absoluteFilePath);
|
|
191
|
+
const isScreenshot = commandName === 'assertScreenshot';
|
|
192
|
+
const isOptional = commandsWithOptionalFiles.has(commandName);
|
|
148
193
|
const buildError = (error) => `Flow file "${absoluteFilePath}" has a command "${commandName}" that references a ${error} ${JSON.stringify(command)}`;
|
|
149
194
|
const processFilePath = (relativePath) => {
|
|
150
|
-
|
|
195
|
+
// A JS/variable-interpolated path (`screenshots/${DCD_DEVICE}/home`) can't
|
|
196
|
+
// be resolved without running the flow. Skip it rather than guessing — the
|
|
197
|
+
// config.yaml `includedPaths` key is how those files get bundled.
|
|
198
|
+
if (relativePath.includes('${'))
|
|
199
|
+
return;
|
|
200
|
+
const resolvedRelativePath = isScreenshot
|
|
201
|
+
? normalizeScreenshotPath(relativePath)
|
|
202
|
+
: relativePath;
|
|
203
|
+
const absoluteFilePath = path.normalize(path.resolve(directory, resolvedRelativePath));
|
|
151
204
|
const error = checkFile(absoluteFilePath);
|
|
152
|
-
if (error)
|
|
205
|
+
if (error) {
|
|
206
|
+
// Optional references drop out entirely when missing: pushing them onto
|
|
207
|
+
// `files` would put a non-existent path into the zip manifest.
|
|
208
|
+
if (isOptional)
|
|
209
|
+
return;
|
|
153
210
|
errors.push(buildError(error));
|
|
211
|
+
}
|
|
154
212
|
files.push(absoluteFilePath);
|
|
155
213
|
};
|
|
156
214
|
// simple command — processFilePath already resolves against `directory`
|
|
@@ -163,10 +221,15 @@ export const checkIfFilesExistInWorkspace = (commandName, command, absoluteFileP
|
|
|
163
221
|
processFilePath(file);
|
|
164
222
|
}
|
|
165
223
|
}
|
|
166
|
-
// object command
|
|
224
|
+
// object command. `file` is addMedia/runFlow/runScript; `path` is
|
|
225
|
+
// assertScreenshot's own key for the same thing.
|
|
167
226
|
const x = command; // prevent annoying ts error
|
|
168
|
-
if (typeof command === 'object' &&
|
|
169
|
-
|
|
227
|
+
if (typeof command === 'object' && !Array.isArray(command)) {
|
|
228
|
+
if (x?.file)
|
|
229
|
+
processFilePath(x.file);
|
|
230
|
+
if (isScreenshot && typeof x?.path === 'string')
|
|
231
|
+
processFilePath(x.path);
|
|
232
|
+
}
|
|
170
233
|
return { errors, files };
|
|
171
234
|
};
|
|
172
235
|
const checkFile = (filePath) => {
|
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
* file path. Segment comparison (not `startsWith`) so sibling dirs like
|
|
4
4
|
* `flows`/`flows-extra` can't merge, and the file segment itself is never
|
|
5
5
|
* consumed. Returns '' when the paths share no root at all (or none are given).
|
|
6
|
+
*
|
|
7
|
+
* `includedFiles` (config.yaml `includedPaths`) must be folded in for the same
|
|
8
|
+
* reason referenced files are: the zip strips this root as an anchored prefix,
|
|
9
|
+
* so a file outside it would get a non-relative entry name. Folding them in
|
|
10
|
+
* can raise the root — flows in `flows/` beside baselines in `screenshots/`
|
|
11
|
+
* shifts it from `<root>/flows` to `<root>`, so flow keys gain a `flows/`
|
|
12
|
+
* segment. That shift is what preserves the flow→baseline relative offset
|
|
13
|
+
* Maestro resolves against, and is already how `addMedia` behaves.
|
|
6
14
|
*/
|
|
7
|
-
export declare function computeCommonRoot(testFileNames: string[], referencedFiles: string[]): string;
|
|
15
|
+
export declare function computeCommonRoot(testFileNames: string[], referencedFiles: string[], includedFiles?: string[]): string;
|
|
8
16
|
export interface FlowMetadataEntry {
|
|
9
17
|
flowName: string;
|
|
10
18
|
tags: string[];
|
|
@@ -12,9 +12,21 @@ import { toPortableRelativePath } from '../utils/paths.js';
|
|
|
12
12
|
* file path. Segment comparison (not `startsWith`) so sibling dirs like
|
|
13
13
|
* `flows`/`flows-extra` can't merge, and the file segment itself is never
|
|
14
14
|
* consumed. Returns '' when the paths share no root at all (or none are given).
|
|
15
|
+
*
|
|
16
|
+
* `includedFiles` (config.yaml `includedPaths`) must be folded in for the same
|
|
17
|
+
* reason referenced files are: the zip strips this root as an anchored prefix,
|
|
18
|
+
* so a file outside it would get a non-relative entry name. Folding them in
|
|
19
|
+
* can raise the root — flows in `flows/` beside baselines in `screenshots/`
|
|
20
|
+
* shifts it from `<root>/flows` to `<root>`, so flow keys gain a `flows/`
|
|
21
|
+
* segment. That shift is what preserves the flow→baseline relative offset
|
|
22
|
+
* Maestro resolves against, and is already how `addMedia` behaves.
|
|
15
23
|
*/
|
|
16
|
-
export function computeCommonRoot(testFileNames, referencedFiles) {
|
|
17
|
-
const pathsShortestToLongest = [
|
|
24
|
+
export function computeCommonRoot(testFileNames, referencedFiles, includedFiles = []) {
|
|
25
|
+
const pathsShortestToLongest = [
|
|
26
|
+
...testFileNames,
|
|
27
|
+
...referencedFiles,
|
|
28
|
+
...includedFiles,
|
|
29
|
+
].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
|
|
18
30
|
if (pathsShortestToLongest.length === 0)
|
|
19
31
|
return '';
|
|
20
32
|
const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep));
|
|
@@ -45,9 +45,29 @@ export declare function deviceFromResultRow(r: {
|
|
|
45
45
|
config?: unknown;
|
|
46
46
|
simulator_name?: string | null;
|
|
47
47
|
}): TestDevice | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Was this row cancelled because a newer run from the same CI context
|
|
50
|
+
* replaced it (`dcd cloud --cancel-previous`)?
|
|
51
|
+
*
|
|
52
|
+
* Read through a structural cast for the same reason deviceFromResultRow
|
|
53
|
+
* does: the committed generated types are regenerated wholesale from dev's
|
|
54
|
+
* swagger and lag the API, and this must work against an API that already
|
|
55
|
+
* sends the field.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isSupersededRow(r: unknown): boolean;
|
|
58
|
+
/** The upload that superseded this run, for the console link. */
|
|
59
|
+
export declare function supersedingUploadId(results: unknown[]): string | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* The superseding run's console link, derived from this run's.
|
|
62
|
+
*
|
|
63
|
+
* The `result` parameter deep-links a single test, and that id belongs to
|
|
64
|
+
* THIS upload — carrying it over would point at a result the newer upload
|
|
65
|
+
* does not contain. Swap the upload id and drop it.
|
|
66
|
+
*/
|
|
67
|
+
export declare function supersedingConsoleUrl(consoleUrl: string, uploadId: string, supersededBy: string): string;
|
|
48
68
|
export interface PollingResult {
|
|
49
69
|
consoleUrl: string;
|
|
50
|
-
status: 'FAILED' | 'PASSED';
|
|
70
|
+
status: 'FAILED' | 'PASSED' | 'SUPERSEDED';
|
|
51
71
|
tests: Array<{
|
|
52
72
|
/** Device this result ran on (present when the API reports it). */
|
|
53
73
|
device?: TestDevice;
|
|
@@ -38,6 +38,45 @@ export function deviceFromResultRow(r) {
|
|
|
38
38
|
googlePlay: sim ? /(_PLAY|-play)$/.test(sim) : undefined,
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Was this row cancelled because a newer run from the same CI context
|
|
43
|
+
* replaced it (`dcd cloud --cancel-previous`)?
|
|
44
|
+
*
|
|
45
|
+
* Read through a structural cast for the same reason deviceFromResultRow
|
|
46
|
+
* does: the committed generated types are regenerated wholesale from dev's
|
|
47
|
+
* swagger and lag the API, and this must work against an API that already
|
|
48
|
+
* sends the field.
|
|
49
|
+
*/
|
|
50
|
+
export function isSupersededRow(r) {
|
|
51
|
+
const reason = r
|
|
52
|
+
?.cancellation_reason;
|
|
53
|
+
return typeof reason === 'string' && reason.startsWith('superseded_by:');
|
|
54
|
+
}
|
|
55
|
+
/** The upload that superseded this run, for the console link. */
|
|
56
|
+
export function supersedingUploadId(results) {
|
|
57
|
+
for (const r of results) {
|
|
58
|
+
const reason = r
|
|
59
|
+
?.cancellation_reason;
|
|
60
|
+
if (typeof reason === 'string' && reason.startsWith('superseded_by:')) {
|
|
61
|
+
return reason.slice('superseded_by:'.length) || undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The superseding run's console link, derived from this run's.
|
|
68
|
+
*
|
|
69
|
+
* The `result` parameter deep-links a single test, and that id belongs to
|
|
70
|
+
* THIS upload — carrying it over would point at a result the newer upload
|
|
71
|
+
* does not contain. Swap the upload id and drop it.
|
|
72
|
+
*/
|
|
73
|
+
export function supersedingConsoleUrl(consoleUrl, uploadId, supersededBy) {
|
|
74
|
+
return consoleUrl
|
|
75
|
+
.replace(uploadId, supersededBy)
|
|
76
|
+
.replace(/&result=[^&]*/, '')
|
|
77
|
+
.replace(/\?result=[^&]*&/, '?')
|
|
78
|
+
.replace(/\?result=[^&]*$/, '');
|
|
79
|
+
}
|
|
41
80
|
/**
|
|
42
81
|
* Service for polling test results from the API
|
|
43
82
|
*/
|
|
@@ -226,13 +265,23 @@ export class ResultsPollingService {
|
|
|
226
265
|
}
|
|
227
266
|
buildPollingResult(results, uploadId, consoleUrl, testMetadata) {
|
|
228
267
|
const resultsWithoutEarlierTries = this.filterLatestResults(results);
|
|
268
|
+
// ANY superseded row settles the whole verdict, even alongside a test
|
|
269
|
+
// that had genuinely failed before the newer run replaced this one: this
|
|
270
|
+
// run no longer speaks for the commit, so failing the build on its behalf
|
|
271
|
+
// is wrong. Actions reaches the same conclusion — a cancelled run's
|
|
272
|
+
// conclusion is `cancelled`, whatever had already failed inside it. The
|
|
273
|
+
// failure is still printed and still in `tests[]`; only the exit code
|
|
274
|
+
// changes.
|
|
275
|
+
const superseded = resultsWithoutEarlierTries.some(isSupersededRow);
|
|
229
276
|
return {
|
|
230
277
|
consoleUrl,
|
|
231
278
|
// Anything other than an explicit pass (CANCELLED, ERROR, a status we
|
|
232
279
|
// don't know about yet) must fail the run — this gates CI exit codes.
|
|
233
|
-
status:
|
|
234
|
-
? '
|
|
235
|
-
: '
|
|
280
|
+
status: superseded
|
|
281
|
+
? 'SUPERSEDED'
|
|
282
|
+
: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
|
|
283
|
+
? 'PASSED'
|
|
284
|
+
: 'FAILED',
|
|
236
285
|
tests: resultsWithoutEarlierTries.map((r) => ({
|
|
237
286
|
// r carries config/simulator_name at runtime; the committed generated
|
|
238
287
|
// types lag the API (regenerated wholesale from dev's swagger), so read
|
|
@@ -259,8 +308,12 @@ export class ResultsPollingService {
|
|
|
259
308
|
const pending = statusCounts.PENDING || 0;
|
|
260
309
|
const queued = statusCounts.QUEUED || 0;
|
|
261
310
|
const running = statusCounts.RUNNING || 0;
|
|
311
|
+
// CANCELLED is terminal, so it counts as completed. Without it a
|
|
312
|
+
// cancelled or superseded run's footer sticks at "8/12 completed"
|
|
313
|
+
// forever, having already stopped polling.
|
|
314
|
+
const cancelled = statusCounts.CANCELLED || 0;
|
|
262
315
|
const total = results.length;
|
|
263
|
-
const completed = passed + failed;
|
|
316
|
+
const completed = passed + failed + cancelled;
|
|
264
317
|
const summary = formatTestSummary({
|
|
265
318
|
completed,
|
|
266
319
|
failed,
|
|
@@ -386,6 +439,27 @@ export class ResultsPollingService {
|
|
|
386
439
|
}
|
|
387
440
|
this.displayFinalResults(updatedResults, consoleUrl, json, logger);
|
|
388
441
|
const output = this.buildPollingResult(updatedResults, uploadId, consoleUrl, testMetadata);
|
|
442
|
+
if (output.status === 'SUPERSEDED') {
|
|
443
|
+
// Exit 0: a newer run of the same CI context replaced this one, so
|
|
444
|
+
// failing the build here would fail it for work nobody is waiting on.
|
|
445
|
+
// Falls through to the success return below — RunFailedError, and with
|
|
446
|
+
// it the exit code 2 in `dcd cloud`, is never reached.
|
|
447
|
+
if (logger && !json) {
|
|
448
|
+
const newer = supersedingUploadId(updatedResults);
|
|
449
|
+
logger('\n');
|
|
450
|
+
logger(ui.warn('Run superseded by a newer run from the same CI context — exiting 0'));
|
|
451
|
+
if (newer) {
|
|
452
|
+
logger(ui.branch(ui.fields([
|
|
453
|
+
[
|
|
454
|
+
'superseded by',
|
|
455
|
+
colors.url(supersedingConsoleUrl(consoleUrl, uploadId, newer)),
|
|
456
|
+
],
|
|
457
|
+
])));
|
|
458
|
+
}
|
|
459
|
+
logger('\n');
|
|
460
|
+
}
|
|
461
|
+
return output;
|
|
462
|
+
}
|
|
389
463
|
if (output.status === 'FAILED') {
|
|
390
464
|
if (debug && logger) {
|
|
391
465
|
logger(`[DEBUG] Some tests failed, returning failed status`);
|
|
@@ -6,6 +6,13 @@ export interface TestSubmissionConfig {
|
|
|
6
6
|
androidNoSnapshot?: boolean;
|
|
7
7
|
apiUrl?: string;
|
|
8
8
|
appBinaryId: string;
|
|
9
|
+
/**
|
|
10
|
+
* Ask the API to cancel the still-queued tests of the previous run from
|
|
11
|
+
* the same CI context. Sent as its own field rather than inside `config`,
|
|
12
|
+
* which is stamped onto every result row and shipped to the runner — this
|
|
13
|
+
* is a one-off submission directive, not run configuration.
|
|
14
|
+
*/
|
|
15
|
+
cancelPrevious?: boolean;
|
|
9
16
|
cliVersion: string;
|
|
10
17
|
commonRoot: string;
|
|
11
18
|
continueOnFailure?: boolean;
|
|
@@ -20,8 +20,8 @@ export class TestSubmissionService {
|
|
|
20
20
|
* @returns The flow zip buffer, its SHA-256, and the string-encoded fields
|
|
21
21
|
*/
|
|
22
22
|
async buildTestPayload(config) {
|
|
23
|
-
const { apiUrl, appBinaryId, encrypt = false, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
|
|
24
|
-
const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
|
|
23
|
+
const { apiUrl, appBinaryId, encrypt = false, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], cancelPrevious = false, googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
|
|
24
|
+
const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, includedFiles = [], referencedFiles, sequence, workspaceConfig, } = executionPlan;
|
|
25
25
|
const { flows: sequentialFlows = [] } = sequence ?? {};
|
|
26
26
|
const envObject = this.parseKeyValuePairs(env);
|
|
27
27
|
const metadataObject = this.parseKeyValuePairs(metadata);
|
|
@@ -42,6 +42,17 @@ export class TestSubmissionService {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
// Logged separately from referencedFiles: these come from config.yaml's
|
|
46
|
+
// `includedPaths` rather than from a flow command, and they can raise the
|
|
47
|
+
// common root (see computeCommonRoot) — which shows up here as every flow
|
|
48
|
+
// key gaining a leading directory segment.
|
|
49
|
+
if (includedFiles.length > 0) {
|
|
50
|
+
this.logDebug(debug, logger, `[DEBUG] Uploading ${includedFiles.length} file(s) from \`includedPaths\`:`);
|
|
51
|
+
for (const file of includedFiles) {
|
|
52
|
+
const normalizedPath = this.normalizeFilePath(file, commonRoot);
|
|
53
|
+
this.logDebug(debug, logger, `[DEBUG] - ${normalizedPath}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
this.logDebug(debug, logger, `[DEBUG] Compressing files from path: ${flowFile}`);
|
|
46
57
|
const plaintextZip = await compressFilesFromRelativePath(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml')
|
|
47
58
|
? path.dirname(flowFile)
|
|
@@ -50,6 +61,7 @@ export class TestSubmissionService {
|
|
|
50
61
|
...referencedFiles,
|
|
51
62
|
...testFileNames,
|
|
52
63
|
...sequentialFlows,
|
|
64
|
+
...includedFiles,
|
|
53
65
|
]),
|
|
54
66
|
], commonRoot);
|
|
55
67
|
this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${plaintextZip.length} bytes`);
|
|
@@ -144,6 +156,11 @@ export class TestSubmissionService {
|
|
|
144
156
|
fields.metadata = JSON.stringify(metadataPayload);
|
|
145
157
|
this.logDebug(debug, logger, `[DEBUG] Sending metadata to API: ${JSON.stringify(metadataPayload)}`);
|
|
146
158
|
}
|
|
159
|
+
// Only sent when asked for, so every other submission's wire shape is
|
|
160
|
+
// unchanged and the flag is simply ignored by an older API.
|
|
161
|
+
if (cancelPrevious) {
|
|
162
|
+
fields.cancelPrevious = 'true';
|
|
163
|
+
}
|
|
147
164
|
this.setOptionalFields(fields, {
|
|
148
165
|
androidApiLevel,
|
|
149
166
|
androidDevice,
|
|
@@ -12,6 +12,7 @@ export declare const WorkspaceConfigSchema: z.ZodObject<{
|
|
|
12
12
|
flowsOrder: z.ZodArray<z.ZodString>;
|
|
13
13
|
}, z.core.$strip>>>;
|
|
14
14
|
flows: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
|
|
15
|
+
includedPaths: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
|
|
15
16
|
includeTags: z.ZodOptional<z.ZodNullable<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>>;
|
|
16
17
|
local: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
17
18
|
deterministicOrder: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
@@ -42,6 +42,7 @@ export const WorkspaceConfigSchema = z.looseObject({
|
|
|
42
42
|
excludeTags: tagList.nullish(),
|
|
43
43
|
executionOrder: ExecutionOrderSchema.nullish(),
|
|
44
44
|
flows: z.array(z.string()).nullish(),
|
|
45
|
+
includedPaths: z.array(z.string()).nullish(),
|
|
45
46
|
includeTags: tagList.nullish(),
|
|
46
47
|
local: z
|
|
47
48
|
.looseObject({ deterministicOrder: z.boolean().nullish() })
|
|
@@ -80,10 +81,15 @@ export const WORKSPACE_CONFIG_KEYS = new Set(Object.keys(WorkspaceConfigSchema.s
|
|
|
80
81
|
* Near-misses that aren't just a casing slip on a real key. Keyed lowercase.
|
|
81
82
|
*/
|
|
82
83
|
const KEY_ALIASES = {
|
|
84
|
+
assets: 'includedPaths',
|
|
83
85
|
continueonfailure: 'executionOrder.continueOnFailure',
|
|
84
86
|
excludetag: 'excludeTags',
|
|
87
|
+
files: 'includedPaths',
|
|
85
88
|
floworder: 'executionOrder.flowsOrder',
|
|
86
89
|
flowsorder: 'executionOrder.flowsOrder',
|
|
90
|
+
includedpath: 'includedPaths',
|
|
91
|
+
includefiles: 'includedPaths',
|
|
92
|
+
includepaths: 'includedPaths',
|
|
87
93
|
includetag: 'includeTags',
|
|
88
94
|
tags: 'includeTags / excludeTags',
|
|
89
95
|
};
|
package/package.json
CHANGED
|
@@ -57,9 +57,19 @@
|
|
|
57
57
|
},
|
|
58
58
|
"repository": {
|
|
59
59
|
"type": "git",
|
|
60
|
-
"url": "https://devicecloud.
|
|
60
|
+
"url": "git+https://github.com/devicecloud-dev/dcd-cli.git"
|
|
61
61
|
},
|
|
62
|
-
"
|
|
62
|
+
"scripts": {
|
|
63
|
+
"dcd": "tsx src/index.ts",
|
|
64
|
+
"build": "shx rm -rf dist && tsc -b && shx chmod +x dist/index.js dist/mcp/index.js",
|
|
65
|
+
"build:binaries": "node scripts/build-binaries.mjs",
|
|
66
|
+
"lint": "eslint src test --ext .ts",
|
|
67
|
+
"prepare": "pnpm build && husky",
|
|
68
|
+
"test": "node scripts/test-runner.mjs",
|
|
69
|
+
"test:unit": "node scripts/test-runner.mjs --unit",
|
|
70
|
+
"typecheck": "tsc --noEmit -p tsconfig.test.json"
|
|
71
|
+
},
|
|
72
|
+
"version": "5.6.0-beta.3",
|
|
63
73
|
"bugs": {
|
|
64
74
|
"url": "https://discord.gg/gm3mJwcNw8"
|
|
65
75
|
},
|
|
@@ -72,13 +82,43 @@
|
|
|
72
82
|
"testing"
|
|
73
83
|
],
|
|
74
84
|
"types": "dist/index.d.ts",
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
"packageManager": "pnpm@10.17.1",
|
|
86
|
+
"pnpm": {
|
|
87
|
+
"overrides": {
|
|
88
|
+
"js-yaml@<3.14.2": ">=3.14.2",
|
|
89
|
+
"js-yaml@>=4.0.0 <4.2.0": ">=4.2.0",
|
|
90
|
+
"js-yaml@>=5.0.0 <5.2.2": ">=5.2.2",
|
|
91
|
+
"tar@<7.5.16": ">=7.5.16",
|
|
92
|
+
"@isaacs/brace-expansion": ">=5.0.1",
|
|
93
|
+
"fast-xml-parser": ">=5.5.7",
|
|
94
|
+
"flatted": ">=3.4.2",
|
|
95
|
+
"lodash": ">=4.18.0",
|
|
96
|
+
"@xmldom/xmldom": ">=0.9.12",
|
|
97
|
+
"minimatch@<3.1.4": "3.1.4",
|
|
98
|
+
"minimatch@>=5.0.0 <5.1.8": "5.1.8",
|
|
99
|
+
"minimatch@>=9.0.0 <9.0.7": "9.0.7",
|
|
100
|
+
"minimatch@>=10.0.0 <10.2.3": "10.2.3",
|
|
101
|
+
"picomatch@<2.3.2": "2.3.2",
|
|
102
|
+
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
|
103
|
+
"serialize-javascript": ">=7.0.5",
|
|
104
|
+
"ajv@<7.0.0": "6.14.0",
|
|
105
|
+
"diff@>=4.0.0 <6.0.0": "4.0.4",
|
|
106
|
+
"diff@>=6.0.0": "8.0.3",
|
|
107
|
+
"brace-expansion@<1.1.16": "1.1.16",
|
|
108
|
+
"brace-expansion@>=2.0.0 <2.0.3": "2.0.3",
|
|
109
|
+
"brace-expansion@>=3.0.0 <5.0.9": "5.0.9",
|
|
110
|
+
"ws@>=8.0.0 <8.21.0": "8.21.0",
|
|
111
|
+
"esbuild@<0.28.1": ">=0.28.1",
|
|
112
|
+
"qs@<6.16.0": "6.16.0",
|
|
113
|
+
"fast-uri@>=3.0.0 <3.1.6": "3.1.6",
|
|
114
|
+
"hono@>=4.0.0 <4.13.5": "4.13.5",
|
|
115
|
+
"micromatch>picomatch": "^2.3.2",
|
|
116
|
+
"tinyglobby>picomatch": "^4.0.4"
|
|
117
|
+
},
|
|
118
|
+
"auditConfig": {
|
|
119
|
+
"ignoreGhsas": [
|
|
120
|
+
"GHSA-frvp-7c67-39w9"
|
|
121
|
+
]
|
|
122
|
+
}
|
|
83
123
|
}
|
|
84
|
-
}
|
|
124
|
+
}
|