dd-trace 5.117.0 → 5.118.0
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/ci/diagnose.js +162 -21
- package/ci/init.js +0 -1
- package/ci/runbook.md +187 -180
- package/ci/test-optimization-validation/approval-artifacts.js +3 -1
- package/ci/test-optimization-validation/approval.js +52 -15
- package/ci/test-optimization-validation/ci-command-candidate.js +26 -32
- package/ci/test-optimization-validation/ci-discovery.js +1 -1
- package/ci/test-optimization-validation/ci-package-scripts.js +141 -0
- package/ci/test-optimization-validation/ci-remediation.js +112 -39
- package/ci/test-optimization-validation/cli.js +516 -681
- package/ci/test-optimization-validation/command-blocker.js +188 -33
- package/ci/test-optimization-validation/command-output-policy.js +3 -26
- package/ci/test-optimization-validation/command-runner.js +189 -245
- package/ci/test-optimization-validation/environment.js +90 -0
- package/ci/test-optimization-validation/executable.js +159 -388
- package/ci/test-optimization-validation/framework-adapters/cucumber.js +119 -0
- package/ci/test-optimization-validation/framework-adapters/cypress.js +107 -0
- package/ci/test-optimization-validation/framework-adapters/playwright.js +181 -0
- package/ci/test-optimization-validation/generated-files.js +75 -13
- package/ci/test-optimization-validation/generated-test-contract.js +283 -0
- package/ci/test-optimization-validation/generated-verifier.js +49 -16
- package/ci/test-optimization-validation/literal-environment.js +57 -0
- package/ci/test-optimization-validation/manifest-loader.js +2 -22
- package/ci/test-optimization-validation/manifest-scaffold.js +816 -515
- package/ci/test-optimization-validation/manifest-schema.js +494 -702
- package/ci/test-optimization-validation/offline-fixtures.js +4 -1
- package/ci/test-optimization-validation/plan-writer.js +243 -984
- package/ci/test-optimization-validation/preflight-runner.js +49 -55
- package/ci/test-optimization-validation/redaction.js +2 -1
- package/ci/test-optimization-validation/report-writer.js +391 -1357
- package/ci/test-optimization-validation/result-semantics.js +86 -0
- package/ci/test-optimization-validation/runner-command.js +249 -0
- package/ci/test-optimization-validation/runner-contract.js +664 -0
- package/ci/test-optimization-validation/scenarios/auto-test-retries.js +29 -3
- package/ci/test-optimization-validation/scenarios/basic-reporting.js +240 -543
- package/ci/test-optimization-validation/scenarios/ci-wiring.js +450 -670
- package/ci/test-optimization-validation/scenarios/early-flake-detection.js +4 -3
- package/ci/test-optimization-validation/scenarios/helpers.js +67 -9
- package/ci/test-optimization-validation/scenarios/test-management.js +4 -3
- package/ci/test-optimization-validation/source-text.js +87 -0
- package/ci/test-optimization-validation/test-output.js +12 -22
- package/ext/tags.d.ts +1 -0
- package/index.d.ts +7 -0
- package/package.json +2 -2
- package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/openai-agents.js +31 -0
- package/packages/datadog-instrumentations/src/openai-agents.js +159 -0
- package/packages/datadog-plugin-openai-agents/src/index.js +74 -0
- package/packages/datadog-plugin-openai-agents/src/integration.js +503 -0
- package/packages/datadog-plugin-openai-agents/src/processor.js +108 -0
- package/packages/datadog-plugin-openai-agents/src/util.js +60 -0
- package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +48 -49
- package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +161 -33
- package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +6 -4
- package/packages/dd-trace/src/config/generated-config-types.d.ts +2 -0
- package/packages/dd-trace/src/config/supported-configurations.json +7 -0
- package/packages/dd-trace/src/encode/span-stats.js +7 -1
- package/packages/dd-trace/src/exporter.js +16 -9
- package/packages/dd-trace/src/llmobs/index.js +9 -1
- package/packages/dd-trace/src/llmobs/plugins/ai/util.js +104 -22
- package/packages/dd-trace/src/llmobs/plugins/openai/index.js +5 -9
- package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +20 -0
- package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +321 -0
- package/packages/dd-trace/src/llmobs/sdk.js +2 -1
- package/packages/dd-trace/src/llmobs/span_processor.js +7 -2
- package/packages/dd-trace/src/llmobs/tagger.js +23 -3
- package/packages/dd-trace/src/llmobs/util.js +56 -3
- package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +3 -6
- package/packages/dd-trace/src/openfeature/configuration_source.js +5 -8
- package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +5 -1
- package/packages/dd-trace/src/plugins/ci_plugin.js +9 -3
- package/packages/dd-trace/src/plugins/index.js +1 -0
- package/packages/dd-trace/src/plugins/outbound.js +4 -1
- package/packages/dd-trace/src/span_processor.js +1 -1
- package/packages/dd-trace/src/span_stats.js +22 -4
- package/ci/test-optimization-validation/command-suitability.js +0 -471
- package/ci/test-optimization-validation/init-probe-preload.js +0 -163
- package/ci/test-optimization-validation/init-probe.js +0 -246
- package/ci/test-optimization-validation/late-initialization.js +0 -63
- package/ci/test-optimization-validation/local-command.js +0 -163
- package/ci/test-optimization-validation/setup-runner.js +0 -97
- package/ci/test-optimization-validation-manifest.schema.json +0 -1
|
@@ -1,862 +1,654 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const path = require('path')
|
|
3
|
+
const path = require('node:path')
|
|
4
4
|
|
|
5
5
|
const { getArtifactId } = require('./artifact-id')
|
|
6
6
|
const {
|
|
7
7
|
MAX_GENERATED_FILES,
|
|
8
8
|
getGeneratedFileContentError,
|
|
9
9
|
} = require('./generated-file-policy')
|
|
10
|
-
const {
|
|
10
|
+
const { getGeneratedTestContractError } = require('./generated-test-contract')
|
|
11
|
+
const { hasUnsafeInvisibleCharacter, sanitizeString } = require('./redaction')
|
|
11
12
|
const {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
sanitizeString,
|
|
17
|
-
} = require('./redaction')
|
|
18
|
-
|
|
19
|
-
const FRAMEWORKS = new Set([
|
|
20
|
-
'jest',
|
|
21
|
-
'vitest',
|
|
22
|
-
'mocha',
|
|
23
|
-
'cucumber',
|
|
24
|
-
'cypress',
|
|
25
|
-
'playwright',
|
|
26
|
-
'node:test',
|
|
27
|
-
'ava',
|
|
28
|
-
'tap',
|
|
29
|
-
'jasmine',
|
|
30
|
-
'karma',
|
|
31
|
-
'uvu',
|
|
32
|
-
'testcafe',
|
|
33
|
-
'custom',
|
|
34
|
-
'unknown',
|
|
35
|
-
])
|
|
13
|
+
getRunnerArgsError,
|
|
14
|
+
getRunnerEnvironmentError,
|
|
15
|
+
getRunnerInputError,
|
|
16
|
+
} = require('./runner-contract')
|
|
36
17
|
|
|
18
|
+
const FRAMEWORKS = new Set(['cucumber', 'cypress', 'jest', 'mocha', 'playwright', 'vitest'])
|
|
37
19
|
const STATUSES = new Set([
|
|
38
|
-
'runnable',
|
|
39
20
|
'detected_not_runnable',
|
|
40
|
-
'requires_external_service',
|
|
41
21
|
'requires_manual_setup',
|
|
22
|
+
'runnable',
|
|
42
23
|
'unsupported_by_validator',
|
|
43
|
-
'unknown',
|
|
44
24
|
])
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
55
|
-
const MAX_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
|
|
56
|
-
const MAX_FRAMEWORKS = 100
|
|
57
|
-
const MAX_MANIFEST_ARRAY_ENTRIES = 1000
|
|
58
|
-
const MAX_SETUP_COMMANDS = 100
|
|
59
|
-
const MAX_VALIDATION_ERRORS = 50
|
|
60
|
-
const MAX_REPRESENTATIVE_TESTS = 1000
|
|
61
|
-
const SECRET_PLACEHOLDER = 'dd-validation-placeholder'
|
|
62
|
-
const SAFE_SECRET_FIELD_VALUES = new Set(['', '0', '1', 'false', 'true', 'none', 'disabled'])
|
|
63
|
-
const COMMAND_FIELDS = new Set([
|
|
25
|
+
const INITIALIZATION_STATUSES = new Set(['configured', 'not_configured', 'unknown'])
|
|
26
|
+
const TRANSPORT_MODES = new Set(['agent', 'agentless', 'none', 'unknown'])
|
|
27
|
+
const GENERATED_SCENARIOS = new Set(['basic-pass', 'atr-fail-once', 'test-management-target'])
|
|
28
|
+
const GENERATED_EXIT_CODES = {
|
|
29
|
+
'basic-pass': 0,
|
|
30
|
+
'atr-fail-once': 1,
|
|
31
|
+
'test-management-target': 0,
|
|
32
|
+
}
|
|
33
|
+
const FORBIDDEN_EXECUTION_FIELDS = new Set([
|
|
64
34
|
'argv',
|
|
65
|
-
'
|
|
66
|
-
'
|
|
67
|
-
'
|
|
68
|
-
'
|
|
69
|
-
'
|
|
35
|
+
'existingTestCommand',
|
|
36
|
+
'forcedLocalCommand',
|
|
37
|
+
'isolationTestCandidate',
|
|
38
|
+
'isolationTestCandidates',
|
|
39
|
+
'localTestCandidates',
|
|
40
|
+
'runCommand',
|
|
41
|
+
'setup',
|
|
70
42
|
'shell',
|
|
71
43
|
'shellCommand',
|
|
72
|
-
'shellReason',
|
|
73
|
-
'timeoutMs',
|
|
74
44
|
'usesShell',
|
|
75
|
-
'id',
|
|
76
|
-
'outputPaths',
|
|
77
45
|
])
|
|
46
|
+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
47
|
+
const SECRET_ENV_PATTERN =
|
|
48
|
+
/(?:^|_)(?:API_?KEY|AUTH(?:ORIZATION)?|CREDENTIALS?|PASS(?:WORD|WD)?|PRIVATE_?KEY|SECRET|TOKEN)(?:_|$)/i
|
|
49
|
+
const EXECUTION_ENV_PATTERN =
|
|
50
|
+
/^(?:(?:BASH_ENV|ENV|NODE_EXTRA_CA_CERTS|NODE_PATH|NODE_REPL_EXTERNAL_MODULE|NODE_TLS_REJECT_UNAUTHORIZED)$|LD_|DYLD_)/i
|
|
51
|
+
const MAX_ARRAY_ENTRIES = 1000
|
|
52
|
+
const MAX_FRAMEWORKS = 100
|
|
53
|
+
const MAX_STRING_BYTES = 256 * 1024
|
|
54
|
+
const MAX_TIMEOUT_MS = 30 * 60 * 1000
|
|
55
|
+
const MAX_VALIDATION_ERRORS = 50
|
|
78
56
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
]
|
|
84
|
-
|
|
85
|
-
'basic-pass': 0,
|
|
86
|
-
'atr-fail-once': 1,
|
|
87
|
-
'test-management-target': 0,
|
|
88
|
-
}
|
|
89
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Validates a data-only Test Optimization manifest.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} manifest parsed manifest
|
|
61
|
+
* @returns {string[]} bounded validation errors
|
|
62
|
+
*/
|
|
90
63
|
function validateManifest (manifest) {
|
|
91
64
|
const errors = createErrorCollector()
|
|
65
|
+
if (!isObject(manifest)) return ['Manifest must be a JSON object.']
|
|
92
66
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
requiredObject(manifest, 'environment', errors)
|
|
100
|
-
requiredArray(manifest, 'frameworks', errors)
|
|
101
|
-
if (Array.isArray(manifest.frameworks) && manifest.frameworks.length === 0) {
|
|
102
|
-
errors.push('frameworks must include at least one framework entry.')
|
|
103
|
-
}
|
|
104
|
-
validateArrayLimit(manifest, 'frameworks', MAX_FRAMEWORKS, errors)
|
|
105
|
-
validateArrayLimit(manifest, 'omitted', MAX_MANIFEST_ARRAY_ENTRIES, errors)
|
|
106
|
-
validateArrayLimit(manifest, 'omittedTestCommands', MAX_MANIFEST_ARRAY_ENTRIES, errors)
|
|
107
|
-
|
|
108
|
-
if (manifest.repository) {
|
|
109
|
-
requiredAbsolutePath(manifest.repository, 'root', errors)
|
|
110
|
-
}
|
|
67
|
+
requiredString(manifest, 'schemaVersion', 'manifest', errors)
|
|
68
|
+
if (manifest.schemaVersion !== '2.0') errors.push('schemaVersion must be "2.0".')
|
|
69
|
+
requiredObject(manifest, 'repository', 'manifest', errors)
|
|
70
|
+
requiredObject(manifest, 'environment', 'manifest', errors)
|
|
71
|
+
requiredArray(manifest, 'frameworks', 'manifest', errors)
|
|
72
|
+
rejectExecutionFields(manifest, 'manifest', errors)
|
|
111
73
|
|
|
112
|
-
if (manifest.
|
|
113
|
-
|
|
74
|
+
if (isObject(manifest.repository)) {
|
|
75
|
+
requiredAbsolutePath(manifest.repository, 'root', 'repository', errors)
|
|
114
76
|
}
|
|
115
77
|
|
|
116
78
|
if (Array.isArray(manifest.frameworks)) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
validateDuplicateRunnableCoverage(frameworks, errors)
|
|
121
|
-
validateGeneratedPathCollisions(frameworks, errors)
|
|
122
|
-
for (const [index, framework] of frameworks.entries()) {
|
|
123
|
-
validateFramework(framework, index, errors)
|
|
79
|
+
if (manifest.frameworks.length === 0) errors.push('frameworks must not be empty.')
|
|
80
|
+
if (manifest.frameworks.length > MAX_FRAMEWORKS) {
|
|
81
|
+
errors.push(`frameworks must contain at most ${MAX_FRAMEWORKS} entries.`)
|
|
124
82
|
}
|
|
83
|
+
validateFrameworks(manifest, errors)
|
|
125
84
|
}
|
|
126
85
|
|
|
127
|
-
|
|
128
|
-
|
|
86
|
+
validateStringArray(manifest.omitted, 'omitted', errors)
|
|
87
|
+
validateAllStrings(manifest, 'manifest', errors)
|
|
129
88
|
return errors.finalize()
|
|
130
89
|
}
|
|
131
90
|
|
|
132
91
|
/**
|
|
133
|
-
*
|
|
92
|
+
* Validates framework entries and cross-framework uniqueness.
|
|
134
93
|
*
|
|
135
|
-
* @param {object
|
|
136
|
-
* @param {{push: function(string): void}} errors
|
|
94
|
+
* @param {object} manifest validation manifest
|
|
95
|
+
* @param {{push: function(string): void, full: function(): boolean}} errors error collector
|
|
137
96
|
* @returns {void}
|
|
138
97
|
*/
|
|
139
|
-
function
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const frameworkPaths = new Map([
|
|
144
|
-
...limitedArray(strategy?.files, MAX_GENERATED_FILES).map(file => file?.path),
|
|
145
|
-
...limitedArray(strategy?.cleanupPaths, MAX_MANIFEST_ARRAY_ENTRIES),
|
|
146
|
-
].filter(filename => typeof filename === 'string' && path.isAbsolute(filename))
|
|
147
|
-
.map(filename => [path.normalize(filename), filename]))
|
|
148
|
-
|
|
149
|
-
for (const [key, filename] of frameworkPaths) {
|
|
150
|
-
const previous = seen.get(key)
|
|
151
|
-
if (previous === undefined) {
|
|
152
|
-
seen.set(key, index)
|
|
153
|
-
continue
|
|
154
|
-
}
|
|
155
|
-
errors.push(
|
|
156
|
-
`frameworks[${index}].generatedTestStrategy path ${JSON.stringify(filename)} conflicts with ` +
|
|
157
|
-
`frameworks[${previous}].generatedTestStrategy. Generated files and cleanup paths must be unique across ` +
|
|
158
|
-
'framework entries.'
|
|
159
|
-
)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
98
|
+
function validateFrameworks (manifest, errors) {
|
|
99
|
+
const ids = new Set()
|
|
100
|
+
const artifactIds = new Set()
|
|
101
|
+
const generatedPaths = new Map()
|
|
163
102
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
framework: framework.framework,
|
|
170
|
-
projectRoot: framework.project?.root,
|
|
171
|
-
existingTestCommand: commandExecutionShape(framework.existingTestCommand),
|
|
172
|
-
ciWiringCommand: commandExecutionShape(framework.ciWiringCommand),
|
|
173
|
-
ciInitializesDatadog: commandInitializesDatadog(framework.ciWiringCommand),
|
|
174
|
-
})
|
|
175
|
-
const previous = seen.get(key)
|
|
176
|
-
if (previous === undefined) {
|
|
177
|
-
seen.set(key, index)
|
|
103
|
+
for (const [index, framework] of manifest.frameworks.slice(0, MAX_FRAMEWORKS).entries()) {
|
|
104
|
+
if (errors.full()) return
|
|
105
|
+
const prefix = `frameworks[${index}]`
|
|
106
|
+
if (!isObject(framework)) {
|
|
107
|
+
errors.push(`${prefix} must be an object.`)
|
|
178
108
|
continue
|
|
179
109
|
}
|
|
180
|
-
errors.push(
|
|
181
|
-
`frameworks[${index}] duplicates runnable framework and CI command coverage from frameworks[${previous}]. ` +
|
|
182
|
-
'Keep one representative framework entry and record the other CI job as an omitted or duplicate candidate.'
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function validateUniqueArtifactIds (frameworks, errors) {
|
|
188
|
-
const seen = new Map()
|
|
189
|
-
for (const [index, framework] of frameworks.entries()) {
|
|
190
|
-
if (typeof framework?.id !== 'string') continue
|
|
191
|
-
const artifactId = getArtifactId(framework.id)
|
|
192
|
-
const previous = seen.get(artifactId)
|
|
193
|
-
if (previous !== undefined && previous !== framework.id) {
|
|
194
|
-
errors.push(
|
|
195
|
-
`frameworks[${index}].id collides with another framework artifact identifier after normalization.`
|
|
196
|
-
)
|
|
197
|
-
} else {
|
|
198
|
-
seen.set(artifactId, framework.id)
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function commandExecutionShape (command) {
|
|
204
|
-
if (!command) return null
|
|
205
|
-
return {
|
|
206
|
-
argv: command.argv,
|
|
207
|
-
cwd: command.cwd,
|
|
208
|
-
shell: command.shell,
|
|
209
|
-
shellCommand: command.shellCommand,
|
|
210
|
-
usesShell: Boolean(command.usesShell),
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function commandInitializesDatadog (command) {
|
|
215
|
-
const values = [
|
|
216
|
-
...(command?.argv || []),
|
|
217
|
-
command?.shellCommand,
|
|
218
|
-
command?.env?.NODE_OPTIONS,
|
|
219
|
-
].filter(Boolean).join(' ')
|
|
220
|
-
return /dd-trace\/ci\/init/.test(values)
|
|
221
|
-
}
|
|
222
110
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
111
|
+
requiredString(framework, 'id', prefix, errors)
|
|
112
|
+
requiredString(framework, 'framework', prefix, errors)
|
|
113
|
+
enumString(framework, 'status', STATUSES, prefix, errors)
|
|
114
|
+
requiredObject(framework, 'project', prefix, errors)
|
|
115
|
+
rejectExecutionFields(framework, prefix, errors)
|
|
226
116
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
containedPath(repositoryRoot, framework.project?.root, `${prefix}.project.root`, errors)
|
|
232
|
-
containedPath(repositoryRoot, framework.project?.packageJson, `${prefix}.project.packageJson`, errors)
|
|
233
|
-
for (const [configIndex, configFile] of
|
|
234
|
-
limitedArray(framework.project?.configFiles, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
|
|
235
|
-
containedPath(repositoryRoot, configFile, `${prefix}.project.configFiles[${configIndex}]`, errors)
|
|
117
|
+
if (typeof framework.framework === 'string' &&
|
|
118
|
+
framework.status !== 'unsupported_by_validator' &&
|
|
119
|
+
!FRAMEWORKS.has(framework.framework)) {
|
|
120
|
+
errors.push(`${prefix}.framework must be one of: ${[...FRAMEWORKS].join(', ')}.`)
|
|
236
121
|
}
|
|
237
122
|
|
|
238
|
-
|
|
239
|
-
|
|
123
|
+
if (typeof framework.id === 'string') {
|
|
124
|
+
if (ids.has(framework.id)) errors.push(`${prefix}.id must be unique.`)
|
|
125
|
+
ids.add(framework.id)
|
|
126
|
+
const artifactId = getArtifactId(framework.id)
|
|
127
|
+
if (artifactIds.has(artifactId)) errors.push(`${prefix}.id collides after artifact normalization.`)
|
|
128
|
+
artifactIds.add(artifactId)
|
|
240
129
|
}
|
|
241
130
|
|
|
242
|
-
|
|
243
|
-
|
|
131
|
+
validateProject(manifest.repository?.root, framework.project, `${prefix}.project`, errors)
|
|
132
|
+
validateCiWiring(manifest.repository?.root, framework.ciWiring, `${prefix}.ciWiring`, errors)
|
|
244
133
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
containedPath(
|
|
253
|
-
repositoryRoot,
|
|
254
|
-
cleanupPath,
|
|
255
|
-
`${prefix}.generatedTestStrategy.cleanupPaths[${cleanupIndex}]`,
|
|
256
|
-
errors
|
|
257
|
-
)
|
|
258
|
-
}
|
|
259
|
-
for (const [scenarioIndex, scenario] of
|
|
260
|
-
limitedArray(strategy?.scenarios, GENERATED_SCENARIO_IDS.size).entries()) {
|
|
261
|
-
for (const [identityIndex, identity] of
|
|
262
|
-
limitedArray(scenario?.testIdentities, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
|
|
263
|
-
containedPath(
|
|
264
|
-
repositoryRoot,
|
|
265
|
-
identity?.file,
|
|
266
|
-
`${prefix}.generatedTestStrategy.scenarios[${scenarioIndex}].testIdentities[${identityIndex}].file`,
|
|
267
|
-
errors
|
|
268
|
-
)
|
|
134
|
+
if (framework.status === 'runnable') {
|
|
135
|
+
validateRunnableFramework(manifest.repository?.root, framework, prefix, generatedPaths, errors)
|
|
136
|
+
} else {
|
|
137
|
+
for (const field of ['validation', 'preflight', 'generatedTestStrategy']) {
|
|
138
|
+
if (framework[field] !== undefined) {
|
|
139
|
+
errors.push(`${prefix}.${field} must be omitted when status is not runnable.`)
|
|
140
|
+
}
|
|
269
141
|
}
|
|
270
142
|
}
|
|
143
|
+
validateStringArray(framework.notes, `${prefix}.notes`, errors)
|
|
271
144
|
}
|
|
272
145
|
}
|
|
273
146
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Validates project metadata.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} repositoryRoot repository root
|
|
151
|
+
* @param {object} project project metadata
|
|
152
|
+
* @param {string} prefix error prefix
|
|
153
|
+
* @param {{push: function(string): void}} errors error collector
|
|
154
|
+
* @returns {void}
|
|
155
|
+
*/
|
|
156
|
+
function validateProject (repositoryRoot, project, prefix, errors) {
|
|
157
|
+
if (!isObject(project)) return
|
|
158
|
+
requiredString(project, 'name', prefix, errors)
|
|
159
|
+
requiredAbsolutePath(project, 'root', prefix, errors)
|
|
160
|
+
requiredAbsolutePath(project, 'packageJson', prefix, errors)
|
|
161
|
+
containedPath(repositoryRoot, project.root, `${prefix}.root`, errors)
|
|
162
|
+
containedPath(repositoryRoot, project.packageJson, `${prefix}.packageJson`, errors)
|
|
163
|
+
if (Array.isArray(project.configFiles)) {
|
|
164
|
+
if (project.configFiles.length > 20) errors.push(`${prefix}.configFiles must contain at most 20 entries.`)
|
|
165
|
+
for (const [index, filename] of project.configFiles.slice(0, 20).entries()) {
|
|
166
|
+
absolutePathValue(filename, `${prefix}.configFiles[${index}]`, errors)
|
|
167
|
+
containedPath(repositoryRoot, filename, `${prefix}.configFiles[${index}]`, errors)
|
|
286
168
|
}
|
|
169
|
+
} else {
|
|
170
|
+
errors.push(`${prefix}.configFiles must be an array.`)
|
|
287
171
|
}
|
|
288
|
-
return commands
|
|
289
172
|
}
|
|
290
173
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Validates direct-runner fields and generated recipes.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} repositoryRoot repository root
|
|
178
|
+
* @param {object} framework runnable framework entry
|
|
179
|
+
* @param {string} prefix error prefix
|
|
180
|
+
* @param {Map<string, string>} generatedPaths generated paths used by other frameworks
|
|
181
|
+
* @param {{push: function(string): void}} errors error collector
|
|
182
|
+
* @returns {void}
|
|
183
|
+
*/
|
|
184
|
+
function validateRunnableFramework (repositoryRoot, framework, prefix, generatedPaths, errors) {
|
|
185
|
+
if (!isObject(framework.validation)) {
|
|
186
|
+
errors.push(`${prefix}.validation must be an object for a runnable framework.`)
|
|
301
187
|
return
|
|
302
188
|
}
|
|
303
189
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
190
|
+
const validation = framework.validation
|
|
191
|
+
rejectExecutionFields(validation, `${prefix}.validation`, errors)
|
|
192
|
+
requiredAbsolutePath(validation, 'runner', `${prefix}.validation`, errors)
|
|
193
|
+
requiredAbsolutePath(validation, 'testFile', `${prefix}.validation`, errors)
|
|
194
|
+
if (!['bounded_direct_runner', 'instrumented_event_identity'].includes(validation.selectorScope)) {
|
|
195
|
+
errors.push(
|
|
196
|
+
`${prefix}.validation.selectorScope must be bounded_direct_runner or instrumented_event_identity.`
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
containedPath(repositoryRoot, validation.runner, `${prefix}.validation.runner`, errors)
|
|
200
|
+
containedPath(repositoryRoot, validation.testFile, `${prefix}.validation.testFile`, errors)
|
|
201
|
+
const runnerArgsError = getRunnerArgsError(framework.framework, validation.runnerArgs)
|
|
202
|
+
if (runnerArgsError) errors.push(`${prefix}.validation.runnerArgs ${runnerArgsError}.`)
|
|
203
|
+
if (validation.omittedRunnerOptions !== undefined) {
|
|
204
|
+
validateStringArray(validation.omittedRunnerOptions, `${prefix}.validation.omittedRunnerOptions`, errors)
|
|
205
|
+
for (const option of Array.isArray(validation.omittedRunnerOptions) ? validation.omittedRunnerOptions : []) {
|
|
206
|
+
if (!['--run', '--typecheck'].includes(option)) {
|
|
207
|
+
errors.push(`${prefix}.validation.omittedRunnerOptions contains unsupported option ${option}.`)
|
|
310
208
|
}
|
|
311
209
|
}
|
|
312
210
|
}
|
|
313
|
-
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
enumString(framework, 'framework', FRAMEWORKS, errors, prefix)
|
|
327
|
-
enumString(framework, 'status', STATUSES, errors, prefix)
|
|
328
|
-
requiredObject(framework, 'project', errors, prefix)
|
|
329
|
-
|
|
330
|
-
if (framework.project) {
|
|
331
|
-
requiredAbsolutePath(framework.project, 'root', errors, `${prefix}.project`)
|
|
332
|
-
optionalAbsolutePath(framework.project, 'packageJson', errors, `${prefix}.project`)
|
|
333
|
-
optionalAbsolutePathArray(framework.project, 'configFiles', errors, `${prefix}.project`)
|
|
334
|
-
validateArrayLimit(framework.project, 'configFiles', MAX_MANIFEST_ARRAY_ENTRIES, errors, `${prefix}.project`)
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
if (framework.status === 'runnable') {
|
|
338
|
-
requiredCommand(framework, 'existingTestCommand', errors, prefix, { datadogClean: true })
|
|
339
|
-
validateDatadogCleanCommand(framework.existingTestCommand, `${prefix}.existingTestCommand`, errors)
|
|
340
|
-
requiredObject(framework, 'preflight', errors, prefix)
|
|
341
|
-
validatePreflight(framework.preflight, `${prefix}.preflight`, errors)
|
|
342
|
-
requiredObject(framework, 'ciWiring', errors, prefix)
|
|
343
|
-
} else {
|
|
344
|
-
requireNonEmptyNotes(framework, errors, prefix)
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (framework.ciWiringCommand) {
|
|
348
|
-
requiredCommand(framework, 'ciWiringCommand', errors, prefix)
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (framework.ciWiring) {
|
|
352
|
-
validateCiWiring(framework, prefix, errors)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (framework.forcedLocalCommand !== undefined) {
|
|
356
|
-
errors.push(
|
|
357
|
-
`${prefix}.forcedLocalCommand is not supported. Use the focused existingTestCommand for Basic Reporting ` +
|
|
358
|
-
'and ciWiringCommand for the CI-shaped replay.'
|
|
211
|
+
const runnerEnvironmentError = getRunnerEnvironmentError(validation.environment)
|
|
212
|
+
if (runnerEnvironmentError) errors.push(`${prefix}.validation.environment ${runnerEnvironmentError}.`)
|
|
213
|
+
if (!runnerArgsError &&
|
|
214
|
+
!runnerEnvironmentError &&
|
|
215
|
+
typeof framework.project?.root === 'string' &&
|
|
216
|
+
typeof repositoryRoot === 'string' &&
|
|
217
|
+
Array.isArray(framework.project?.configFiles)) {
|
|
218
|
+
const runnerInputError = getRunnerInputError(
|
|
219
|
+
validation.runnerArgs,
|
|
220
|
+
validation.environment,
|
|
221
|
+
framework.project?.root,
|
|
222
|
+
repositoryRoot,
|
|
223
|
+
framework.project?.configFiles
|
|
359
224
|
)
|
|
225
|
+
if (runnerInputError) errors.push(`${prefix}.validation runner configuration ${runnerInputError}.`)
|
|
360
226
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
227
|
+
validateStringArray(validation.requiredEnvVars, `${prefix}.validation.requiredEnvVars`, errors)
|
|
228
|
+
for (const name of validation.requiredEnvVars || []) {
|
|
229
|
+
if (!ENV_NAME_PATTERN.test(name)) {
|
|
230
|
+
errors.push(`${prefix}.validation.requiredEnvVars contains an invalid environment name.`)
|
|
231
|
+
}
|
|
232
|
+
if (/^(?:DD_|DATADOG_|OTEL_|NODE_OPTIONS$|TS_NODE_PROJECT$)/i.test(name)) {
|
|
233
|
+
errors.push(
|
|
234
|
+
`${prefix}.validation.requiredEnvVars must not inherit Datadog, OpenTelemetry, NODE_OPTIONS, or ` +
|
|
235
|
+
'TS_NODE_PROJECT.'
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
if (SECRET_ENV_PATTERN.test(name)) {
|
|
239
|
+
errors.push(`${prefix}.validation.requiredEnvVars must not inherit secret-like environment variables.`)
|
|
370
240
|
}
|
|
241
|
+
if (EXECUTION_ENV_PATTERN.test(name)) {
|
|
242
|
+
errors.push(`${prefix}.validation.requiredEnvVars must not inherit executable-loading environment variables.`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!Number.isInteger(validation.timeoutMs) || validation.timeoutMs < 1 || validation.timeoutMs > MAX_TIMEOUT_MS) {
|
|
246
|
+
errors.push(`${prefix}.validation.timeoutMs must be an integer between 1 and ${MAX_TIMEOUT_MS}.`)
|
|
371
247
|
}
|
|
372
248
|
|
|
373
|
-
if (framework.generatedTestStrategy) {
|
|
374
|
-
|
|
249
|
+
if (!isObject(framework.generatedTestStrategy)) {
|
|
250
|
+
errors.push(`${prefix}.generatedTestStrategy must be an object for a runnable framework.`)
|
|
251
|
+
return
|
|
375
252
|
}
|
|
253
|
+
validateGeneratedStrategy(repositoryRoot, framework, prefix, generatedPaths, errors)
|
|
376
254
|
}
|
|
377
255
|
|
|
378
256
|
/**
|
|
379
|
-
* Validates
|
|
257
|
+
* Validates canonical generated test data.
|
|
380
258
|
*
|
|
381
|
-
* @param {
|
|
382
|
-
* @param {
|
|
383
|
-
* @param {
|
|
259
|
+
* @param {string} repositoryRoot repository root
|
|
260
|
+
* @param {object} framework framework entry
|
|
261
|
+
* @param {string} prefix framework error prefix
|
|
262
|
+
* @param {Map<string, string>} generatedPaths generated paths used by other frameworks
|
|
263
|
+
* @param {{push: function(string): void}} errors error collector
|
|
384
264
|
* @returns {void}
|
|
385
265
|
*/
|
|
386
|
-
function
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
errors.push(`${prefix}.maxTestCount must be a positive integer.`)
|
|
391
|
-
} else if (preflight.maxTestCount > MAX_REPRESENTATIVE_TESTS) {
|
|
392
|
-
errors.push(`${prefix}.maxTestCount must not exceed ${MAX_REPRESENTATIVE_TESTS}.`)
|
|
266
|
+
function validateGeneratedStrategy (repositoryRoot, framework, prefix, generatedPaths, errors) {
|
|
267
|
+
const strategy = framework.generatedTestStrategy
|
|
268
|
+
if (!['planned', 'verified', 'not_possible'].includes(strategy.status)) {
|
|
269
|
+
errors.push(`${prefix}.generatedTestStrategy.status must be planned, verified, or not_possible.`)
|
|
393
270
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (!['planned', 'verified', 'proposed', 'not_possible'].includes(strategy.status)) {
|
|
398
|
-
errors.push(`${prefix}.status must be planned, verified, proposed, or not_possible.`)
|
|
271
|
+
if (strategy.status === 'not_possible') {
|
|
272
|
+
requiredString(strategy, 'reason', `${prefix}.generatedTestStrategy`, errors)
|
|
273
|
+
return
|
|
399
274
|
}
|
|
400
275
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
validateCompleteGeneratedScenarioSet(strategy, prefix, errors)
|
|
407
|
-
} else if ((strategy.status === 'proposed' || strategy.status === 'not_possible') &&
|
|
408
|
-
(typeof strategy.reason !== 'string' || strategy.reason.trim() === '')) {
|
|
409
|
-
errors.push(`${prefix}.reason must explain why the generated test strategy is ${strategy.status}.`)
|
|
410
|
-
}
|
|
276
|
+
requiredAbsolutePath(strategy, 'testDirectory', `${prefix}.generatedTestStrategy`, errors)
|
|
277
|
+
containedPath(repositoryRoot, strategy.testDirectory, `${prefix}.generatedTestStrategy.testDirectory`, errors)
|
|
278
|
+
requiredArray(strategy, 'files', `${prefix}.generatedTestStrategy`, errors)
|
|
279
|
+
requiredArray(strategy, 'scenarios', `${prefix}.generatedTestStrategy`, errors)
|
|
280
|
+
requiredArray(strategy, 'cleanupPaths', `${prefix}.generatedTestStrategy`, errors)
|
|
411
281
|
|
|
412
282
|
if (Array.isArray(strategy.files)) {
|
|
413
283
|
if (strategy.files.length > MAX_GENERATED_FILES) {
|
|
414
|
-
errors.push(`${prefix}.files must contain at most ${MAX_GENERATED_FILES}
|
|
284
|
+
errors.push(`${prefix}.generatedTestStrategy.files must contain at most ${MAX_GENERATED_FILES} entries.`)
|
|
415
285
|
}
|
|
416
286
|
for (const [index, file] of strategy.files.slice(0, MAX_GENERATED_FILES).entries()) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
287
|
+
const filePrefix = `${prefix}.generatedTestStrategy.files[${index}]`
|
|
288
|
+
if (!isObject(file)) {
|
|
289
|
+
errors.push(`${filePrefix} must be an object.`)
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
requiredAbsolutePath(file, 'path', filePrefix, errors)
|
|
293
|
+
validateStringArray(file.contentLines, `${filePrefix}.contentLines`, errors)
|
|
294
|
+
containedPath(repositoryRoot, file.path, `${filePrefix}.path`, errors)
|
|
420
295
|
const policyError = getGeneratedFileContentError(file.contentLines)
|
|
421
|
-
if (policyError) errors.push(`${
|
|
296
|
+
if (policyError) errors.push(`${filePrefix}.contentLines ${policyError}.`)
|
|
297
|
+
claimGeneratedPath(file.path, framework.id, filePrefix, generatedPaths, errors)
|
|
422
298
|
}
|
|
423
299
|
}
|
|
424
300
|
|
|
425
|
-
if (Array.isArray(strategy.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
validateDatadogCleanCommand(scenario.runCommand, `${prefix}.scenarios[${index}].runCommand`, errors)
|
|
432
|
-
validateScenarioIdentities(
|
|
433
|
-
scenario,
|
|
434
|
-
`${prefix}.scenarios[${index}]`,
|
|
435
|
-
errors,
|
|
436
|
-
completeStrategy
|
|
437
|
-
)
|
|
438
|
-
if (completeStrategy) {
|
|
439
|
-
validateGeneratedScenarioOutcome(scenario, `${prefix}.scenarios[${index}]`, errors)
|
|
440
|
-
}
|
|
301
|
+
if (Array.isArray(strategy.cleanupPaths)) {
|
|
302
|
+
for (const [index, filename] of strategy.cleanupPaths.slice(0, MAX_ARRAY_ENTRIES).entries()) {
|
|
303
|
+
const pathPrefix = `${prefix}.generatedTestStrategy.cleanupPaths[${index}]`
|
|
304
|
+
absolutePathValue(filename, pathPrefix, errors)
|
|
305
|
+
containedPath(repositoryRoot, filename, pathPrefix, errors)
|
|
306
|
+
claimGeneratedPath(filename, framework.id, pathPrefix, generatedPaths, errors)
|
|
441
307
|
}
|
|
442
308
|
}
|
|
443
309
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
if (ciWiring.replayability === 'replayable' && !framework.ciWiringCommand) {
|
|
466
|
-
errors.push(`${prefix}.ciWiringCommand is required when ${prefix}.ciWiring.replayability is replayable.`)
|
|
467
|
-
}
|
|
468
|
-
if (ciWiring.replayability === 'not_replayable') {
|
|
469
|
-
if (ciWiring.status === 'pass' || ciWiring.status === 'fail') {
|
|
470
|
-
errors.push(`${prefix}.ciWiring.status must be skip or unknown when replayability is not_replayable.`)
|
|
471
|
-
}
|
|
472
|
-
if (framework.ciWiringCommand) {
|
|
473
|
-
errors.push(`${prefix}.ciWiringCommand must be omitted when ${prefix}.ciWiring.replayability is not_replayable.`)
|
|
474
|
-
}
|
|
475
|
-
if (!hasNonEmptyString(ciWiring.replayBlocker)) {
|
|
476
|
-
errors.push(`${prefix}.ciWiring.replayBlocker must explain why CI replay is not_replayable.`)
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (ciWiring.initialization !== undefined) {
|
|
481
|
-
validateCiInitialization(ciWiring.initialization, `${prefix}.ciWiring.initialization`, errors)
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (ciWiring.initialization?.status === 'not_configured' &&
|
|
485
|
-
commandInitializesDatadog(framework.ciWiringCommand)) {
|
|
486
|
-
errors.push(
|
|
487
|
-
`${prefix}.ciWiring.initialization.status is not_configured, but ${prefix}.ciWiringCommand adds dd-trace ` +
|
|
488
|
-
'initialization. The replay command must preserve the discovered CI configuration; remove the added ' +
|
|
489
|
-
'initialization or correct the initialization status and evidence.'
|
|
490
|
-
)
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (framework.ciWiringCommand) {
|
|
494
|
-
for (const field of ['provider', 'configFile', 'job', 'step', 'whySelected']) {
|
|
495
|
-
requiredString(ciWiring, field, errors, `${prefix}.ciWiring`)
|
|
496
|
-
}
|
|
497
|
-
requiredAbsolutePath(ciWiring, 'configFile', errors, `${prefix}.ciWiring`)
|
|
498
|
-
requiredAbsolutePath(ciWiring, 'workingDirectory', errors, `${prefix}.ciWiring`)
|
|
499
|
-
if (path.resolve(ciWiring.workingDirectory || '') !== path.resolve(framework.ciWiringCommand.cwd || '')) {
|
|
500
|
-
errors.push(`${prefix}.ciWiringCommand.cwd must match ${prefix}.ciWiring.workingDirectory.`)
|
|
501
|
-
}
|
|
502
|
-
if (ciWiring.shell !== undefined && ciWiring.shell !== null) {
|
|
503
|
-
if (typeof ciWiring.shell !== 'string' || ciWiring.shell.trim() === '') {
|
|
504
|
-
errors.push(`${prefix}.ciWiring.shell must be a non-empty string when present.`)
|
|
505
|
-
} else if (hasUnsafeExecutionCharacter(ciWiring.shell)) {
|
|
506
|
-
errors.push(`${prefix}.ciWiring.shell must not contain invisible or control characters.`)
|
|
310
|
+
if (Array.isArray(strategy.scenarios)) {
|
|
311
|
+
if (strategy.scenarios.length !== GENERATED_SCENARIOS.size) {
|
|
312
|
+
errors.push(`${prefix}.generatedTestStrategy.scenarios must contain all three canonical scenarios.`)
|
|
313
|
+
}
|
|
314
|
+
const scenarioIds = new Set()
|
|
315
|
+
for (const [index, scenario] of strategy.scenarios.slice(0, GENERATED_SCENARIOS.size).entries()) {
|
|
316
|
+
const scenarioPrefix = `${prefix}.generatedTestStrategy.scenarios[${index}]`
|
|
317
|
+
if (!isObject(scenario)) {
|
|
318
|
+
errors.push(`${scenarioPrefix} must be an object.`)
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
enumString(scenario, 'id', GENERATED_SCENARIOS, scenarioPrefix, errors)
|
|
322
|
+
rejectExecutionFields(scenario, scenarioPrefix, errors)
|
|
323
|
+
scenarioIds.add(scenario.id)
|
|
324
|
+
validateScenarioIdentity(repositoryRoot, scenario, scenarioPrefix, errors)
|
|
325
|
+
const expected = scenario.expectedWithoutDatadog
|
|
326
|
+
if (!isObject(expected) ||
|
|
327
|
+
expected.exitCode !== GENERATED_EXIT_CODES[scenario.id] ||
|
|
328
|
+
expected.observedTestCount !== 1) {
|
|
329
|
+
errors.push(`${scenarioPrefix}.expectedWithoutDatadog must retain the canonical exit code and one test.`)
|
|
507
330
|
}
|
|
508
331
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if ((ciWiring.status === 'skip' || ciWiring.status === 'unknown') &&
|
|
512
|
-
!hasNonEmptyString(ciWiring.diagnosis) && !hasNonEmptyString(ciWiring.reason)) {
|
|
513
|
-
errors.push(`${prefix}.ciWiring must explain why CI wiring is ${ciWiring.status}.`)
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function validateCiInitialization (initialization, prefix, errors) {
|
|
518
|
-
if (!initialization || typeof initialization !== 'object' || Array.isArray(initialization)) {
|
|
519
|
-
errors.push(`${prefix} must be an object when present.`)
|
|
520
|
-
return
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
if (!CI_INITIALIZATION_STATUSES.has(initialization.status)) {
|
|
524
|
-
errors.push(`${prefix}.status must be configured, not_configured, or unknown.`)
|
|
525
|
-
}
|
|
526
|
-
if (Array.isArray(initialization.evidence)) {
|
|
527
|
-
validateStringArray(initialization, 'evidence', errors, prefix)
|
|
528
|
-
if (initialization.status !== 'unknown' && initialization.evidence.length === 0) {
|
|
529
|
-
errors.push(`${prefix}.evidence must explain the ${initialization.status} conclusion.`)
|
|
332
|
+
for (const id of GENERATED_SCENARIOS) {
|
|
333
|
+
if (!scenarioIds.has(id)) errors.push(`${prefix}.generatedTestStrategy.scenarios is missing ${id}.`)
|
|
530
334
|
}
|
|
531
|
-
} else {
|
|
532
|
-
errors.push(`${prefix}.evidence must be an array.`)
|
|
533
335
|
}
|
|
534
|
-
}
|
|
535
336
|
|
|
536
|
-
|
|
537
|
-
|
|
337
|
+
const contractError = getGeneratedTestContractError(framework)
|
|
338
|
+
if (contractError) errors.push(`${prefix}.generatedTestStrategy ${contractError}`)
|
|
538
339
|
}
|
|
539
340
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Validates one generated test identity.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} repositoryRoot repository root
|
|
345
|
+
* @param {object} scenario generated scenario
|
|
346
|
+
* @param {string} prefix error prefix
|
|
347
|
+
* @param {{push: function(string): void}} errors error collector
|
|
348
|
+
* @returns {void}
|
|
349
|
+
*/
|
|
350
|
+
function validateScenarioIdentity (repositoryRoot, scenario, prefix, errors) {
|
|
351
|
+
if (!Array.isArray(scenario.testIdentities) || scenario.testIdentities.length !== 1) {
|
|
352
|
+
errors.push(`${prefix}.testIdentities must contain exactly one identity.`)
|
|
353
|
+
return
|
|
545
354
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
`${prefix} ${inlineInitialization} and must be Datadog-clean for local validation. ` +
|
|
551
|
-
'Remove the inline initialization; preserve exact CI initialization only in ciWiringCommand.'
|
|
552
|
-
)
|
|
355
|
+
const identity = scenario.testIdentities[0]
|
|
356
|
+
if (!isObject(identity)) {
|
|
357
|
+
errors.push(`${prefix}.testIdentities[0] must be an object.`)
|
|
358
|
+
return
|
|
553
359
|
}
|
|
360
|
+
requiredAbsolutePath(identity, 'file', `${prefix}.testIdentities[0]`, errors)
|
|
361
|
+
requiredString(identity, 'name', `${prefix}.testIdentities[0]`, errors)
|
|
362
|
+
requiredString(identity, 'suite', `${prefix}.testIdentities[0]`, errors)
|
|
363
|
+
containedPath(repositoryRoot, identity.file, `${prefix}.testIdentities[0].file`, errors)
|
|
554
364
|
}
|
|
555
365
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Validates inert CI evidence.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} repositoryRoot repository root
|
|
370
|
+
* @param {object|undefined} ciWiring CI evidence
|
|
371
|
+
* @param {string} prefix error prefix
|
|
372
|
+
* @param {{push: function(string): void}} errors error collector
|
|
373
|
+
* @returns {void}
|
|
374
|
+
*/
|
|
375
|
+
function validateCiWiring (repositoryRoot, ciWiring, prefix, errors) {
|
|
376
|
+
if (ciWiring === undefined) return
|
|
377
|
+
if (!isObject(ciWiring)) {
|
|
378
|
+
errors.push(`${prefix} must be an object.`)
|
|
560
379
|
return
|
|
561
380
|
}
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
for (const scenario of strategy.scenarios.slice(0, GENERATED_SCENARIO_IDS.size)) {
|
|
577
|
-
if (typeof scenario?.id === 'string') seen.add(scenario.id)
|
|
381
|
+
rejectExecutionFields(ciWiring, prefix, errors, new Set(['command']))
|
|
382
|
+
for (const field of ['command', 'job', 'step']) optionalStringOrNull(ciWiring, field, prefix, errors)
|
|
383
|
+
optionalAbsolutePathOrNull(ciWiring, 'configFile', prefix, errors)
|
|
384
|
+
optionalAbsolutePathOrNull(ciWiring, 'workingDirectory', prefix, errors)
|
|
385
|
+
containedPath(repositoryRoot, ciWiring.configFile, `${prefix}.configFile`, errors)
|
|
386
|
+
containedPath(repositoryRoot, ciWiring.workingDirectory, `${prefix}.workingDirectory`, errors)
|
|
387
|
+
if (typeof ciWiring.reviewComplete !== 'boolean') errors.push(`${prefix}.reviewComplete must be a boolean.`)
|
|
388
|
+
validateStringArray(ciWiring.unresolved, `${prefix}.unresolved`, errors)
|
|
389
|
+
|
|
390
|
+
if (!isObject(ciWiring.initialization) ||
|
|
391
|
+
!INITIALIZATION_STATUSES.has(ciWiring.initialization.status)) {
|
|
392
|
+
errors.push(`${prefix}.initialization.status must be configured, not_configured, or unknown.`)
|
|
393
|
+
} else {
|
|
394
|
+
validateStringArray(ciWiring.initialization.evidence, `${prefix}.initialization.evidence`, errors)
|
|
578
395
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
'verified.')
|
|
584
|
-
}
|
|
396
|
+
if (!isObject(ciWiring.transport) || !TRANSPORT_MODES.has(ciWiring.transport.mode)) {
|
|
397
|
+
errors.push(`${prefix}.transport.mode must be agent, agentless, none, or unknown.`)
|
|
398
|
+
} else {
|
|
399
|
+
validateStringArray(ciWiring.transport.evidence, `${prefix}.transport.evidence`, errors)
|
|
585
400
|
}
|
|
586
401
|
}
|
|
587
402
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
403
|
+
/**
|
|
404
|
+
* Rejects execution-shaped keys anywhere in a data object.
|
|
405
|
+
*
|
|
406
|
+
* @param {object} value object to inspect
|
|
407
|
+
* @param {string} prefix error prefix
|
|
408
|
+
* @param {{push: function(string): void}} errors error collector
|
|
409
|
+
* @param {Set<string>} [allowed] explicitly inert keys
|
|
410
|
+
* @returns {void}
|
|
411
|
+
*/
|
|
412
|
+
function rejectExecutionFields (value, prefix, errors, allowed = new Set()) {
|
|
413
|
+
if (!isObject(value)) return
|
|
414
|
+
for (const key of Object.keys(value)) {
|
|
415
|
+
if (FORBIDDEN_EXECUTION_FIELDS.has(key) && !allowed.has(key)) {
|
|
416
|
+
errors.push(`${prefix}.${key} is not supported. Executable commands are validator-owned.`)
|
|
594
417
|
}
|
|
595
|
-
seen.add(framework.id)
|
|
596
418
|
}
|
|
597
419
|
}
|
|
598
420
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Claims a generated path for one framework.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} filename generated path
|
|
425
|
+
* @param {string} owner framework id
|
|
426
|
+
* @param {string} prefix error prefix
|
|
427
|
+
* @param {Map<string, string>} generatedPaths previously claimed paths
|
|
428
|
+
* @param {{push: function(string): void}} errors error collector
|
|
429
|
+
* @returns {void}
|
|
430
|
+
*/
|
|
431
|
+
function claimGeneratedPath (filename, owner, prefix, generatedPaths, errors) {
|
|
432
|
+
if (typeof filename !== 'string' || !path.isAbsolute(filename)) return
|
|
433
|
+
const normalized = path.normalize(filename)
|
|
434
|
+
const previousOwner = generatedPaths.get(normalized)
|
|
435
|
+
if (previousOwner !== undefined && previousOwner !== owner) {
|
|
436
|
+
errors.push(`${prefix} conflicts with another framework's generated or cleanup path.`)
|
|
603
437
|
}
|
|
604
|
-
|
|
438
|
+
generatedPaths.set(normalized, owner)
|
|
605
439
|
}
|
|
606
440
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
441
|
+
/**
|
|
442
|
+
* Validates every string in a bounded object graph.
|
|
443
|
+
*
|
|
444
|
+
* @param {unknown} value current value
|
|
445
|
+
* @param {string} prefix error prefix
|
|
446
|
+
* @param {{push: function(string): void, full: function(): boolean}} errors error collector
|
|
447
|
+
* @returns {void}
|
|
448
|
+
*/
|
|
449
|
+
function validateAllStrings (value, prefix, errors) {
|
|
450
|
+
if (errors.full()) return
|
|
451
|
+
if (typeof value === 'string') {
|
|
452
|
+
if (Buffer.byteLength(value) > MAX_STRING_BYTES) errors.push(`${prefix} exceeds the string size limit.`)
|
|
453
|
+
if (hasUnsafeInvisibleCharacter(value)) errors.push(`${prefix} contains an unsafe invisible character.`)
|
|
613
454
|
return
|
|
614
455
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
validateArrayLimit(
|
|
620
|
-
{ testIdentities: scenario.testIdentities },
|
|
621
|
-
'testIdentities',
|
|
622
|
-
MAX_MANIFEST_ARRAY_ENTRIES,
|
|
623
|
-
errors,
|
|
624
|
-
prefix
|
|
625
|
-
)
|
|
626
|
-
|
|
627
|
-
for (const [index, identity] of scenario.testIdentities.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
|
|
628
|
-
const identityPrefix = `${prefix}.testIdentities[${index}]`
|
|
629
|
-
if (!identity || typeof identity !== 'object' || Array.isArray(identity)) {
|
|
630
|
-
errors.push(`${identityPrefix} must be an object.`)
|
|
631
|
-
continue
|
|
456
|
+
if (Array.isArray(value)) {
|
|
457
|
+
for (const [index, item] of value.slice(0, MAX_ARRAY_ENTRIES).entries()) {
|
|
458
|
+
validateAllStrings(item, `${prefix}[${index}]`, errors)
|
|
632
459
|
}
|
|
633
|
-
requiredString(identity, 'name', errors, identityPrefix)
|
|
634
|
-
if (identity.suite !== undefined && identity.suite !== null && typeof identity.suite !== 'string') {
|
|
635
|
-
errors.push(`${identityPrefix}.suite must be a string or null when present.`)
|
|
636
|
-
}
|
|
637
|
-
optionalAbsolutePath(identity, 'file', errors, identityPrefix)
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
function requiredCommand (target, field, errors, prefix = '', options = {}) {
|
|
642
|
-
const value = target && target[field]
|
|
643
|
-
const key = join(prefix, field)
|
|
644
|
-
if (!value || typeof value !== 'object') {
|
|
645
|
-
errors.push(`${key} must be an object.`)
|
|
646
460
|
return
|
|
647
461
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
if (value.usesShell !== undefined && typeof value.usesShell !== 'boolean') {
|
|
652
|
-
errors.push(`${key}.usesShell must be a boolean when present.`)
|
|
653
|
-
}
|
|
654
|
-
if (value.required !== undefined && typeof value.required !== 'boolean') {
|
|
655
|
-
errors.push(`${key}.required must be a boolean when present.`)
|
|
656
|
-
}
|
|
657
|
-
requiredAbsolutePath(value, 'cwd', errors, key)
|
|
658
|
-
rejectUnresolvedPlaceholder(value.cwd, `${key}.cwd`, errors)
|
|
659
|
-
if (value.shell !== undefined) {
|
|
660
|
-
requiredString(value, 'shell', errors, key)
|
|
661
|
-
rejectUnresolvedPlaceholder(value.shell, `${key}.shell`, errors)
|
|
662
|
-
if (!value.usesShell) errors.push(`${key}.shell requires usesShell to be true.`)
|
|
663
|
-
if (typeof value.shell === 'string' && hasUnsafeExecutionCharacter(value.shell)) {
|
|
664
|
-
errors.push(`${key}.shell must not contain invisible or control characters.`)
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
if (value.usesShell) {
|
|
668
|
-
requiredString(value, 'shellCommand', errors, key)
|
|
669
|
-
rejectUnresolvedPlaceholder(value.shellCommand, `${key}.shellCommand`, errors)
|
|
670
|
-
if (typeof value.shellCommand === 'string' && hasUnsafeInvisibleCharacter(value.shellCommand)) {
|
|
671
|
-
errors.push(`${key}.shellCommand must not contain invisible or control characters.`)
|
|
672
|
-
} else if (typeof value.shellCommand === 'string' && sanitizeString(value.shellCommand) !== value.shellCommand) {
|
|
673
|
-
errors.push(`${key}.shellCommand must not contain inline secret-like values. Put safe placeholders in env.`)
|
|
674
|
-
}
|
|
675
|
-
} else if (!Array.isArray(value.argv) || value.argv.length === 0) {
|
|
676
|
-
errors.push(`${key}.argv must be a non-empty array unless usesShell is true.`)
|
|
677
|
-
} else {
|
|
678
|
-
validateStringArray(value, 'argv', errors, key)
|
|
679
|
-
for (const [index, arg] of value.argv.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
|
|
680
|
-
rejectUnresolvedPlaceholder(arg, `${key}.argv[${index}]`, errors)
|
|
681
|
-
}
|
|
682
|
-
if (value.argv.some(hasUnsafeExecutionCharacter)) {
|
|
683
|
-
errors.push(`${key}.argv must not contain invisible or control characters.`)
|
|
684
|
-
} else if (JSON.stringify(sanitizeForReport(value.argv)) !== JSON.stringify(value.argv)) {
|
|
685
|
-
errors.push(`${key}.argv must not contain inline secret-like values. Put safe placeholders in env.`)
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
if (value.env !== undefined) {
|
|
689
|
-
if (!value.env || typeof value.env !== 'object' || Array.isArray(value.env)) {
|
|
690
|
-
errors.push(`${key}.env must be an object when present.`)
|
|
691
|
-
} else {
|
|
692
|
-
const environmentEntries = Object.entries(value.env)
|
|
693
|
-
if (environmentEntries.length > MAX_MANIFEST_ARRAY_ENTRIES) {
|
|
694
|
-
errors.push(`${key}.env must contain at most ${MAX_MANIFEST_ARRAY_ENTRIES} entries.`)
|
|
695
|
-
}
|
|
696
|
-
for (const [name, envValue] of environmentEntries.slice(0, MAX_MANIFEST_ARRAY_ENTRIES)) {
|
|
697
|
-
if (!ENV_NAME_PATTERN.test(name)) {
|
|
698
|
-
errors.push(`${key}.env contains invalid variable name ${JSON.stringify(name)}.`)
|
|
699
|
-
}
|
|
700
|
-
if (typeof envValue !== 'string') errors.push(`${key}.env.${name} must be a string.`)
|
|
701
|
-
const validatePlaceholder = !(options.datadogClean && name.startsWith('DD_'))
|
|
702
|
-
if (typeof envValue === 'string' && hasUnsafeExecutionCharacter(envValue)) {
|
|
703
|
-
errors.push(`${key}.env.${name} must not contain invisible or control characters.`)
|
|
704
|
-
} else if (validatePlaceholder && typeof envValue === 'string' && containsSecretValue(name, envValue) &&
|
|
705
|
-
envValue !== SECRET_PLACEHOLDER) {
|
|
706
|
-
errors.push(`${key}.env.${name} must use the safe placeholder ${JSON.stringify(SECRET_PLACEHOLDER)}.`)
|
|
707
|
-
}
|
|
708
|
-
rejectUnresolvedPlaceholder(envValue, `${key}.env.${name}`, errors)
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
if (value.requiredEnvVars !== undefined) {
|
|
713
|
-
if (Array.isArray(value.requiredEnvVars)) {
|
|
714
|
-
validateArrayLimit(value, 'requiredEnvVars', MAX_MANIFEST_ARRAY_ENTRIES, errors, key)
|
|
715
|
-
for (const [index] of value.requiredEnvVars.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
|
|
716
|
-
requiredString(value.requiredEnvVars, index, errors, `${key}.requiredEnvVars`)
|
|
717
|
-
}
|
|
718
|
-
} else {
|
|
719
|
-
errors.push(`${key}.requiredEnvVars must be an array when present.`)
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
optionalAbsolutePathArray(value, 'outputPaths', errors, key)
|
|
723
|
-
if (value.timeoutMs !== undefined && (!Number.isFinite(value.timeoutMs) || value.timeoutMs <= 0)) {
|
|
724
|
-
errors.push(`${key}.timeoutMs must be a positive number when present.`)
|
|
725
|
-
} else if (value.timeoutMs > MAX_COMMAND_TIMEOUT_MS) {
|
|
726
|
-
errors.push(`${key}.timeoutMs must not exceed ${MAX_COMMAND_TIMEOUT_MS} ms.`)
|
|
462
|
+
if (isObject(value)) {
|
|
463
|
+
for (const [key, item] of Object.entries(value)) validateAllStrings(item, `${prefix}.${key}`, errors)
|
|
727
464
|
}
|
|
728
465
|
}
|
|
729
466
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
467
|
+
/**
|
|
468
|
+
* Validates a required object.
|
|
469
|
+
*
|
|
470
|
+
* @param {object} object parent object
|
|
471
|
+
* @param {string} field field name
|
|
472
|
+
* @param {string} prefix error prefix
|
|
473
|
+
* @param {{push: function(string): void}} errors error collector
|
|
474
|
+
* @returns {void}
|
|
475
|
+
*/
|
|
476
|
+
function requiredObject (object, field, prefix, errors) {
|
|
477
|
+
if (!isObject(object[field])) errors.push(`${prefix}.${field} must be an object.`)
|
|
733
478
|
}
|
|
734
479
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
480
|
+
/**
|
|
481
|
+
* Validates a required array.
|
|
482
|
+
*
|
|
483
|
+
* @param {object} object parent object
|
|
484
|
+
* @param {string} field field name
|
|
485
|
+
* @param {string} prefix error prefix
|
|
486
|
+
* @param {{push: function(string): void}} errors error collector
|
|
487
|
+
* @returns {void}
|
|
488
|
+
*/
|
|
489
|
+
function requiredArray (object, field, prefix, errors) {
|
|
490
|
+
if (!Array.isArray(object[field])) errors.push(`${prefix}.${field} must be an array.`)
|
|
738
491
|
}
|
|
739
492
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
493
|
+
/**
|
|
494
|
+
* Validates a required string.
|
|
495
|
+
*
|
|
496
|
+
* @param {object} object parent object
|
|
497
|
+
* @param {string} field field name
|
|
498
|
+
* @param {string} prefix error prefix
|
|
499
|
+
* @param {{push: function(string): void}} errors error collector
|
|
500
|
+
* @returns {void}
|
|
501
|
+
*/
|
|
502
|
+
function requiredString (object, field, prefix, errors) {
|
|
503
|
+
if (typeof object?.[field] !== 'string' || object[field].trim() === '') {
|
|
504
|
+
errors.push(`${prefix}.${field} must be a non-empty string.`)
|
|
744
505
|
}
|
|
745
506
|
}
|
|
746
507
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Validates an enum string.
|
|
510
|
+
*
|
|
511
|
+
* @param {object} object parent object
|
|
512
|
+
* @param {string} field field name
|
|
513
|
+
* @param {Set<string>} values accepted values
|
|
514
|
+
* @param {string} prefix error prefix
|
|
515
|
+
* @param {{push: function(string): void}} errors error collector
|
|
516
|
+
* @returns {void}
|
|
517
|
+
*/
|
|
518
|
+
function enumString (object, field, values, prefix, errors) {
|
|
519
|
+
if (!values.has(object?.[field])) {
|
|
520
|
+
errors.push(`${prefix}.${field} must be one of: ${[...values].join(', ')}.`)
|
|
750
521
|
}
|
|
751
522
|
}
|
|
752
523
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
524
|
+
/**
|
|
525
|
+
* Validates a required absolute path.
|
|
526
|
+
*
|
|
527
|
+
* @param {object} object parent object
|
|
528
|
+
* @param {string} field field name
|
|
529
|
+
* @param {string} prefix error prefix
|
|
530
|
+
* @param {{push: function(string): void}} errors error collector
|
|
531
|
+
* @returns {void}
|
|
532
|
+
*/
|
|
533
|
+
function requiredAbsolutePath (object, field, prefix, errors) {
|
|
534
|
+
absolutePathValue(object?.[field], `${prefix}.${field}`, errors)
|
|
757
535
|
}
|
|
758
536
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Validates an absolute path value.
|
|
539
|
+
*
|
|
540
|
+
* @param {unknown} value path value
|
|
541
|
+
* @param {string} prefix error prefix
|
|
542
|
+
* @param {{push: function(string): void}} errors error collector
|
|
543
|
+
* @returns {void}
|
|
544
|
+
*/
|
|
545
|
+
function absolutePathValue (value, prefix, errors) {
|
|
546
|
+
if (typeof value !== 'string' || !path.isAbsolute(value)) errors.push(`${prefix} must be an absolute path.`)
|
|
763
547
|
}
|
|
764
548
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
549
|
+
/**
|
|
550
|
+
* Validates an optional nullable absolute path.
|
|
551
|
+
*
|
|
552
|
+
* @param {object} object parent object
|
|
553
|
+
* @param {string} field field name
|
|
554
|
+
* @param {string} prefix error prefix
|
|
555
|
+
* @param {{push: function(string): void}} errors error collector
|
|
556
|
+
* @returns {void}
|
|
557
|
+
*/
|
|
558
|
+
function optionalAbsolutePathOrNull (object, field, prefix, errors) {
|
|
559
|
+
if (object[field] !== null && object[field] !== undefined &&
|
|
560
|
+
(typeof object[field] !== 'string' || !path.isAbsolute(object[field]))) {
|
|
561
|
+
errors.push(`${prefix}.${field} must be an absolute path or null.`)
|
|
771
562
|
}
|
|
772
563
|
}
|
|
773
564
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
565
|
+
/**
|
|
566
|
+
* Validates an optional nullable string.
|
|
567
|
+
*
|
|
568
|
+
* @param {object} object parent object
|
|
569
|
+
* @param {string} field field name
|
|
570
|
+
* @param {string} prefix error prefix
|
|
571
|
+
* @param {{push: function(string): void}} errors error collector
|
|
572
|
+
* @returns {void}
|
|
573
|
+
*/
|
|
574
|
+
function optionalStringOrNull (object, field, prefix, errors) {
|
|
575
|
+
if (object[field] !== null && object[field] !== undefined && typeof object[field] !== 'string') {
|
|
576
|
+
errors.push(`${prefix}.${field} must be a string or null.`)
|
|
781
577
|
}
|
|
782
578
|
}
|
|
783
579
|
|
|
784
|
-
|
|
785
|
-
|
|
580
|
+
/**
|
|
581
|
+
* Validates a string array when present.
|
|
582
|
+
*
|
|
583
|
+
* @param {unknown} value array value
|
|
584
|
+
* @param {string} prefix error prefix
|
|
585
|
+
* @param {{push: function(string): void}} errors error collector
|
|
586
|
+
* @returns {void}
|
|
587
|
+
*/
|
|
588
|
+
function validateStringArray (value, prefix, errors) {
|
|
786
589
|
if (value === undefined) return
|
|
787
590
|
if (!Array.isArray(value)) {
|
|
788
|
-
errors.push(`${
|
|
591
|
+
errors.push(`${prefix} must be an array of strings.`)
|
|
789
592
|
return
|
|
790
593
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
if (typeof item !== 'string' || !path.isAbsolute(item)) {
|
|
795
|
-
errors.push(`${join(prefix, field)}[${index}] must be an absolute path.`)
|
|
796
|
-
} else if (hasUnsafeExecutionCharacter(item)) {
|
|
797
|
-
errors.push(`${join(prefix, field)}[${index}] must not contain invisible or control characters.`)
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function validateStringArray (target, field, errors, prefix = '') {
|
|
803
|
-
const value = target && target[field]
|
|
804
|
-
if (!Array.isArray(value)) return
|
|
805
|
-
|
|
806
|
-
validateArrayLimit(target, field, MAX_MANIFEST_ARRAY_ENTRIES, errors, prefix)
|
|
807
|
-
for (const [index, item] of value.slice(0, MAX_MANIFEST_ARRAY_ENTRIES).entries()) {
|
|
808
|
-
if (typeof item !== 'string') {
|
|
809
|
-
errors.push(`${join(prefix, field)}[${index}] must be a string.`)
|
|
810
|
-
}
|
|
594
|
+
if (value.length > MAX_ARRAY_ENTRIES) errors.push(`${prefix} must contain at most ${MAX_ARRAY_ENTRIES} entries.`)
|
|
595
|
+
if (value.slice(0, MAX_ARRAY_ENTRIES).some(item => typeof item !== 'string')) {
|
|
596
|
+
errors.push(`${prefix} must contain only strings.`)
|
|
811
597
|
}
|
|
812
598
|
}
|
|
813
599
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
600
|
+
/**
|
|
601
|
+
* Checks lexical repository containment.
|
|
602
|
+
*
|
|
603
|
+
* @param {string} root repository root
|
|
604
|
+
* @param {unknown} filename candidate path
|
|
605
|
+
* @param {string} prefix error prefix
|
|
606
|
+
* @param {{push: function(string): void}} errors error collector
|
|
607
|
+
* @returns {void}
|
|
608
|
+
*/
|
|
609
|
+
function containedPath (root, filename, prefix, errors) {
|
|
610
|
+
if (typeof root !== 'string' || !path.isAbsolute(root) ||
|
|
611
|
+
typeof filename !== 'string' || !path.isAbsolute(filename)) return
|
|
612
|
+
const relative = path.relative(path.resolve(root), path.resolve(filename))
|
|
613
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
614
|
+
errors.push(`${prefix} must be inside repository.root.`)
|
|
818
615
|
}
|
|
819
616
|
}
|
|
820
617
|
|
|
821
|
-
|
|
822
|
-
|
|
618
|
+
/**
|
|
619
|
+
* Returns whether a value is a plain object.
|
|
620
|
+
*
|
|
621
|
+
* @param {unknown} value candidate value
|
|
622
|
+
* @returns {boolean} whether the value is an object
|
|
623
|
+
*/
|
|
624
|
+
function isObject (value) {
|
|
625
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
823
626
|
}
|
|
824
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Creates a bounded error collector.
|
|
630
|
+
*
|
|
631
|
+
* @returns {{push: function(string): void, full: function(): boolean, finalize: function(): string[]}} collector
|
|
632
|
+
*/
|
|
825
633
|
function createErrorCollector () {
|
|
826
634
|
const errors = []
|
|
827
|
-
let omitted =
|
|
828
|
-
|
|
829
|
-
push
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
omitted++
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
return this.length
|
|
839
|
-
},
|
|
635
|
+
let omitted = false
|
|
636
|
+
return {
|
|
637
|
+
push (message) {
|
|
638
|
+
if (errors.length < MAX_VALIDATION_ERRORS) {
|
|
639
|
+
errors.push(sanitizeString(message))
|
|
640
|
+
} else {
|
|
641
|
+
omitted = true
|
|
642
|
+
}
|
|
840
643
|
},
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
if (omitted > 0) {
|
|
844
|
-
Array.prototype.push.call(this, `${omitted} additional validation error(s) omitted.`)
|
|
845
|
-
}
|
|
846
|
-
return this
|
|
847
|
-
},
|
|
644
|
+
full () {
|
|
645
|
+
return errors.length >= MAX_VALIDATION_ERRORS
|
|
848
646
|
},
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
return prefix ? `${prefix}.${field}` : field
|
|
647
|
+
finalize () {
|
|
648
|
+
if (omitted) errors.push('Additional validation errors were omitted.')
|
|
649
|
+
return errors
|
|
650
|
+
},
|
|
651
|
+
}
|
|
855
652
|
}
|
|
856
653
|
|
|
857
|
-
module.exports = {
|
|
858
|
-
MAX_FRAMEWORKS,
|
|
859
|
-
MAX_REPRESENTATIVE_TESTS,
|
|
860
|
-
MAX_VALIDATION_ERRORS,
|
|
861
|
-
validateManifest,
|
|
862
|
-
}
|
|
654
|
+
module.exports = { validateManifest }
|