dd-trace 5.116.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 +3 -3
- package/packages/datadog-instrumentations/src/cucumber.js +11 -12
- package/packages/datadog-instrumentations/src/helpers/channel.js +74 -0
- 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/jest.js +18 -17
- package/packages/datadog-instrumentations/src/mocha/main.js +79 -30
- package/packages/datadog-instrumentations/src/openai-agents.js +159 -0
- package/packages/datadog-instrumentations/src/playwright.js +3 -24
- package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +1 -1
- package/packages/datadog-instrumentations/src/vitest-main.js +14 -26
- package/packages/datadog-instrumentations/src/vitest-util.js +0 -7
- package/packages/datadog-instrumentations/src/vitest-worker.js +5 -8
- package/packages/datadog-plugin-cucumber/src/index.js +2 -1
- package/packages/datadog-plugin-mocha/src/index.js +2 -1
- 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/datadog-plugin-playwright/src/index.js +4 -1
- package/packages/datadog-plugin-vitest/src/index.js +5 -5
- 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/profiling/profilers/space.js +1 -1
- package/packages/dd-trace/src/profiling/profilers/wall.js +1 -0
- 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,14 +1,44 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
/* eslint-disable eslint-rules/eslint-process-env */
|
|
4
|
-
|
|
5
3
|
const fs = require('node:fs')
|
|
6
4
|
const path = require('node:path')
|
|
7
5
|
|
|
8
6
|
const { runDiagnosis } = require('../diagnose')
|
|
7
|
+
const cucumber = require('./framework-adapters/cucumber')
|
|
8
|
+
const cypress = require('./framework-adapters/cypress')
|
|
9
|
+
const playwright = require('./framework-adapters/playwright')
|
|
10
|
+
const {
|
|
11
|
+
GENERATED_SCENARIOS,
|
|
12
|
+
getGeneratedRetryStatePath,
|
|
13
|
+
getGeneratedTestContent,
|
|
14
|
+
} = require('./generated-test-contract')
|
|
9
15
|
const { validateManifest } = require('./manifest-schema')
|
|
16
|
+
const {
|
|
17
|
+
getProjectNodeRunner,
|
|
18
|
+
getRunnerContract,
|
|
19
|
+
getRunnerSearchRoots,
|
|
20
|
+
} = require('./runner-contract')
|
|
10
21
|
|
|
11
|
-
const
|
|
22
|
+
const SUPPORTED_FRAMEWORKS = new Set(['cucumber', 'cypress', 'jest', 'mocha', 'playwright', 'vitest'])
|
|
23
|
+
const MAX_DISCOVERY_ENTRIES = 5000
|
|
24
|
+
const MAX_DIRECTORY_ENTRIES = 1024
|
|
25
|
+
const MAX_FILE_BYTES = 512 * 1024
|
|
26
|
+
const MAX_CI_FILES = 256
|
|
27
|
+
const MAX_CI_REVIEW_TARGETS = 3
|
|
28
|
+
const SKIPPED_DIRECTORIES = new Set([
|
|
29
|
+
'.git',
|
|
30
|
+
'.hg',
|
|
31
|
+
'.svn',
|
|
32
|
+
'.yarn',
|
|
33
|
+
'build',
|
|
34
|
+
'coverage',
|
|
35
|
+
'dd-test-optimization-validation-results',
|
|
36
|
+
'dist',
|
|
37
|
+
'node_modules',
|
|
38
|
+
'out',
|
|
39
|
+
'target',
|
|
40
|
+
'vendor',
|
|
41
|
+
])
|
|
12
42
|
const CI_PATHS = [
|
|
13
43
|
'.github/workflows',
|
|
14
44
|
'.gitlab-ci.yml',
|
|
@@ -18,9 +48,43 @@ const CI_PATHS = [
|
|
|
18
48
|
'azure-pipelines.yml',
|
|
19
49
|
'Jenkinsfile',
|
|
20
50
|
]
|
|
51
|
+
const CONFIG_PATTERNS = {
|
|
52
|
+
cucumber: cucumber.CONFIG_PATTERN,
|
|
53
|
+
cypress: cypress.CONFIG_PATTERN,
|
|
54
|
+
jest: /^(?:jest|config-jest)\.config\.(?:[cm]?[jt]s|json)$/,
|
|
55
|
+
mocha: /^\.mocharc\.(?:json|ya?ml|[cm]?js)$/,
|
|
56
|
+
playwright: playwright.CONFIG_PATTERN,
|
|
57
|
+
vitest: /^(?:vite|vitest)\.config\.[cm]?[jt]s$/,
|
|
58
|
+
}
|
|
59
|
+
const JS_CONFIG_EXTENSIONS = ['js', 'cjs', 'mjs', 'ts', 'cts', 'mts']
|
|
60
|
+
const IMPLICIT_CONFIG_FILENAMES = {
|
|
61
|
+
cucumber: ['json', 'yaml', 'yml', ...JS_CONFIG_EXTENSIONS].map(extension => `cucumber.${extension}`),
|
|
62
|
+
cypress: ['json', ...JS_CONFIG_EXTENSIONS].flatMap(extension => [
|
|
63
|
+
`cypress.${extension}`,
|
|
64
|
+
`cypress.config.${extension}`,
|
|
65
|
+
]),
|
|
66
|
+
jest: ['json', ...JS_CONFIG_EXTENSIONS].flatMap(extension => [
|
|
67
|
+
`jest.config.${extension}`,
|
|
68
|
+
`config-jest.config.${extension}`,
|
|
69
|
+
]),
|
|
70
|
+
mocha: ['json', 'yaml', 'yml', ...JS_CONFIG_EXTENSIONS].map(extension => `.mocharc.${extension}`),
|
|
71
|
+
playwright: JS_CONFIG_EXTENSIONS.map(extension => `playwright.config.${extension}`),
|
|
72
|
+
vitest: JS_CONFIG_EXTENSIONS.flatMap(extension => [
|
|
73
|
+
`vite.config.${extension}`,
|
|
74
|
+
`vitest.config.${extension}`,
|
|
75
|
+
]),
|
|
76
|
+
}
|
|
77
|
+
const TEST_FILE_PATTERN = /^.+[._-](?:test|spec)\.[cm]?[jt]sx?$/
|
|
78
|
+
const BARE_TEST_FILE_PATTERN = /^test\.[cm]?[jt]sx?$/
|
|
79
|
+
const TYPE_ONLY_TEST_PATTERN = /\.(?:test|spec)-d\.[cm]?tsx?$|\.d\.[cm]?ts$/i
|
|
80
|
+
const TYPE_ONLY_DIRECTORY_PATTERN = /(?:^|\/)(?:type[-_]?tests?|typetests?|test-dts?)(?:\/|$)/i
|
|
81
|
+
const LOCAL_SOCKET_PATTERN =
|
|
82
|
+
/(?:\bcreateServer\s*\(|\.listen\s*\(|\b(?:localhost|127\.0\.0\.1)\b|\bcy\.(?:visit|request)\s*\()/
|
|
83
|
+
const CYPRESS_LOCAL_ORIGIN_PATTERN =
|
|
84
|
+
/\bcy\.(?:visit|request)\s*\([\s\S]{0,512}\b(?:https?:\/\/)?(?:localhost|127\.0\.0\.1)(?=[:/'"`\s}])/i
|
|
21
85
|
|
|
22
86
|
/**
|
|
23
|
-
* Creates a schema-valid
|
|
87
|
+
* Creates a schema-valid data-only manifest without executing project code.
|
|
24
88
|
*
|
|
25
89
|
* @param {object} input scaffold inputs
|
|
26
90
|
* @param {string} input.root repository root
|
|
@@ -28,39 +92,37 @@ const CI_PATHS = [
|
|
|
28
92
|
* @returns {object} validation manifest scaffold
|
|
29
93
|
*/
|
|
30
94
|
function createManifestScaffold ({ root, frameworks = new Set() }) {
|
|
31
|
-
const repositoryRoot = path.resolve(root)
|
|
95
|
+
const repositoryRoot = fs.realpathSync(path.resolve(root))
|
|
32
96
|
const diagnosis = runDiagnosis({ root: repositoryRoot, env: {} })
|
|
33
|
-
const
|
|
34
|
-
|
|
97
|
+
const ciDiscovery = discoverCiFiles(repositoryRoot)
|
|
98
|
+
const eligible = diagnosis.eligibleFrameworks.filter(detection => isSelected(detection.id, frameworks))
|
|
99
|
+
const eligibleKinds = new Set(eligible.map(detection => detection.id))
|
|
100
|
+
const detectedOnly = diagnosis.supportedFrameworks.filter(detection => {
|
|
101
|
+
return !eligibleKinds.has(detection.id) && isSelected(detection.id, frameworks)
|
|
35
102
|
})
|
|
36
|
-
const unsupported = diagnosis.unsupportedFrameworks.filter(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (selected.length === 0 && unsupported.length === 0) {
|
|
103
|
+
const unsupported = diagnosis.unsupportedFrameworks.filter(detection => isSelected(detection.id, frameworks))
|
|
104
|
+
|
|
105
|
+
if (eligible.length === 0 && detectedOnly.length === 0 && unsupported.length === 0) {
|
|
40
106
|
throw new Error('No test framework was detected for manifest scaffolding.')
|
|
41
107
|
}
|
|
42
108
|
|
|
43
109
|
const manifest = {
|
|
44
|
-
schemaVersion: '
|
|
110
|
+
schemaVersion: '2.0',
|
|
45
111
|
generatedAt: new Date().toISOString(),
|
|
46
112
|
repository: {
|
|
47
113
|
root: repositoryRoot,
|
|
48
|
-
gitRemote: null,
|
|
49
|
-
gitSha: null,
|
|
50
114
|
packageManager: detectPackageManager(repositoryRoot),
|
|
51
115
|
workspaceManager: detectWorkspaceManager(repositoryRoot),
|
|
52
116
|
},
|
|
53
117
|
environment: {
|
|
54
|
-
os: getManifestOs(process.platform),
|
|
55
|
-
shell: process.env.SHELL || null,
|
|
56
118
|
nodeVersion: process.version,
|
|
57
|
-
|
|
58
|
-
safeEnv: {},
|
|
119
|
+
os: getManifestOs(process.platform),
|
|
59
120
|
},
|
|
60
|
-
ciDiscovery
|
|
121
|
+
ciDiscovery,
|
|
61
122
|
frameworks: [
|
|
62
|
-
...
|
|
63
|
-
...
|
|
123
|
+
...eligible.map(detection => buildFramework(repositoryRoot, detection, ciDiscovery)),
|
|
124
|
+
...detectedOnly.map(detection => buildDetectedOnlyFramework(repositoryRoot, detection, ciDiscovery)),
|
|
125
|
+
...unsupported.map(detection => buildUnsupportedFramework(repositoryRoot, detection, ciDiscovery)),
|
|
64
126
|
],
|
|
65
127
|
omitted: [],
|
|
66
128
|
}
|
|
@@ -73,666 +135,905 @@ function createManifestScaffold ({ root, frameworks = new Set() }) {
|
|
|
73
135
|
}
|
|
74
136
|
|
|
75
137
|
/**
|
|
76
|
-
* Builds
|
|
138
|
+
* Builds one runnable direct-runner framework or a precise setup blocker.
|
|
77
139
|
*
|
|
78
140
|
* @param {string} repositoryRoot repository root
|
|
79
|
-
* @param {object} detection
|
|
80
|
-
* @
|
|
141
|
+
* @param {object} detection eligible framework detection
|
|
142
|
+
* @param {object} ciDiscovery bounded CI discovery result
|
|
143
|
+
* @returns {object} framework manifest entry
|
|
81
144
|
*/
|
|
82
|
-
function
|
|
83
|
-
const packageJsonPath = getDetectionPackageJson(repositoryRoot, detection.locations)
|
|
84
|
-
const projectRoot = path.dirname(packageJsonPath)
|
|
85
|
-
const packageJson = readJson(packageJsonPath) || {}
|
|
86
|
-
const framework = detection.id === 'node-test' ? 'node:test' : detection.id
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
id: `${detection.id}:${getProjectIdentifier(packageJson, projectRoot, repositoryRoot)}`,
|
|
90
|
-
framework,
|
|
91
|
-
frameworkVersion: getInstalledFrameworkVersion(detection.id, projectRoot, packageJson),
|
|
92
|
-
language: 'unknown',
|
|
93
|
-
status: 'unsupported_by_validator',
|
|
94
|
-
supportLevel: 'detected_only',
|
|
95
|
-
project: getProject({ packageJson, packageJsonPath, projectRoot, repositoryRoot, framework }),
|
|
96
|
-
notes: [
|
|
97
|
-
`${detection.name} was detected at ${detection.locations.join(', ') || 'an unknown location'}, but is not ` +
|
|
98
|
-
'supported by this Test Optimization validator.',
|
|
99
|
-
],
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function buildFrameworkScaffold (repositoryRoot, detection) {
|
|
104
|
-
const packageJsonPath = path.resolve(repositoryRoot, detection.commandLocation || 'package.json')
|
|
105
|
-
const projectRoot = path.dirname(packageJsonPath)
|
|
106
|
-
const packageJson = readJson(packageJsonPath) || {}
|
|
145
|
+
function buildFramework (repositoryRoot, detection, ciDiscovery) {
|
|
107
146
|
const framework = detection.id
|
|
147
|
+
const packageJson = getDetectionPackageJson(repositoryRoot, detection.commandLocation)
|
|
148
|
+
const projectRoot = path.dirname(packageJson.path)
|
|
149
|
+
const base = getFrameworkBase({
|
|
150
|
+
ciDiscovery,
|
|
151
|
+
detection,
|
|
152
|
+
framework,
|
|
153
|
+
packageJson,
|
|
154
|
+
projectRoot,
|
|
155
|
+
repositoryRoot,
|
|
156
|
+
})
|
|
108
157
|
|
|
109
|
-
if (!
|
|
158
|
+
if (!SUPPORTED_FRAMEWORKS.has(framework)) {
|
|
110
159
|
return {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
status: 'detected_not_runnable',
|
|
115
|
-
project: getProject({ packageJson, packageJsonPath, projectRoot, repositoryRoot, framework }),
|
|
116
|
-
notes: [`${detection.name} was detected, but automatic generated-test scaffolding is not available.`],
|
|
160
|
+
...base,
|
|
161
|
+
status: 'unsupported_by_validator',
|
|
162
|
+
notes: [`${detection.name} is supported by dd-trace, but this validator has no direct-runner adapter.`],
|
|
117
163
|
}
|
|
118
164
|
}
|
|
119
165
|
|
|
120
|
-
const
|
|
166
|
+
const projectRunner = getProjectNodeRunner(detection.command, projectRoot, repositoryRoot)
|
|
167
|
+
const runner = projectRunner || resolveRunner(framework, projectRoot, repositoryRoot)
|
|
121
168
|
if (!runner) {
|
|
122
169
|
return {
|
|
123
|
-
|
|
124
|
-
framework,
|
|
125
|
-
frameworkVersion: detection.version,
|
|
170
|
+
...base,
|
|
126
171
|
status: 'requires_manual_setup',
|
|
127
|
-
project: getProject({ packageJson, packageJsonPath, projectRoot, repositoryRoot, framework }),
|
|
128
172
|
notes: [
|
|
129
|
-
`${detection.name}
|
|
130
|
-
|
|
173
|
+
`${detection.name} is detected, but its repository-contained executable is unavailable. Install this ` +
|
|
174
|
+
'package normally, then create a fresh validation plan.',
|
|
131
175
|
],
|
|
132
176
|
}
|
|
133
177
|
}
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
178
|
+
|
|
179
|
+
const runnerContract = getRunnerContract(framework, detection.command, projectRoot, repositoryRoot)
|
|
180
|
+
if (runnerContract.error) {
|
|
181
|
+
return {
|
|
182
|
+
...base,
|
|
183
|
+
status: 'requires_manual_setup',
|
|
184
|
+
notes: [
|
|
185
|
+
`${detection.name} runner configuration was not retained because ${runnerContract.error}. ` +
|
|
186
|
+
'Choose a representative test command manually or adjust the project setup, then create a fresh plan.',
|
|
187
|
+
],
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const commandRoots = getRunnerSearchRoots(framework, detection.command, projectRoot, repositoryRoot)
|
|
191
|
+
const representativeRoot = commandRoots[0] || findPreferredRepresentativeRoot(projectRoot, repositoryRoot)
|
|
192
|
+
const representativePackage = readJson(path.join(representativeRoot, 'package.json')) || packageJson.json
|
|
193
|
+
const projectFiles = collectProjectFiles(projectRoot)
|
|
194
|
+
const candidateFiles = representativeRoot === projectRoot
|
|
195
|
+
? projectFiles
|
|
196
|
+
: collectProjectFiles(representativeRoot)
|
|
197
|
+
const candidate = selectRepresentativeTest(
|
|
198
|
+
candidateFiles,
|
|
137
199
|
framework,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
200
|
+
representativeRoot,
|
|
201
|
+
representativePackage.name,
|
|
202
|
+
commandRoots.length > 0
|
|
203
|
+
)
|
|
204
|
+
if (!candidate) {
|
|
205
|
+
return {
|
|
206
|
+
...base,
|
|
207
|
+
status: 'requires_manual_setup',
|
|
208
|
+
notes: [
|
|
209
|
+
`No single ${detection.name} test file could be selected confidently. Choose a normal framework-owned ` +
|
|
210
|
+
'test file or validate this framework manually.',
|
|
211
|
+
],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (candidate.requiresExternalService) {
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
status: 'requires_manual_setup',
|
|
218
|
+
notes: [
|
|
219
|
+
`No self-contained ${detection.name} spec could be selected. The available spec accesses a localhost ` +
|
|
220
|
+
'application that discovery will not start. Start the application through the project\'s normal setup and ' +
|
|
221
|
+
'validate Cypress manually, or add a self-contained spec before creating a fresh plan.',
|
|
222
|
+
],
|
|
223
|
+
}
|
|
224
|
+
}
|
|
148
225
|
|
|
149
226
|
const generatedTestStrategy = buildGeneratedTestStrategy({
|
|
150
|
-
baseCommand: preserveProjectWrapper ? baseCommand : undefined,
|
|
151
227
|
framework,
|
|
152
|
-
packageJson,
|
|
153
228
|
projectRoot,
|
|
154
|
-
representative,
|
|
155
|
-
runner,
|
|
229
|
+
representative: candidate.path,
|
|
156
230
|
})
|
|
231
|
+
const configFiles = [...new Set([
|
|
232
|
+
...runnerContract.inputFiles,
|
|
233
|
+
...getImplicitConfigFiles(framework, projectRoot, repositoryRoot),
|
|
234
|
+
...(representativeRoot === projectRoot
|
|
235
|
+
? []
|
|
236
|
+
: getImplicitConfigFiles(framework, representativeRoot, repositoryRoot)),
|
|
237
|
+
...projectFiles.filter(filename => CONFIG_PATTERNS[framework]?.test(path.basename(filename))).slice(0, 5),
|
|
238
|
+
])]
|
|
239
|
+
if (configFiles.length > 20) {
|
|
240
|
+
return {
|
|
241
|
+
...base,
|
|
242
|
+
status: 'requires_manual_setup',
|
|
243
|
+
notes: [
|
|
244
|
+
`${detection.name} loads more configuration files than the validator can approval-bind safely. ` +
|
|
245
|
+
'Use a narrower project setup before creating a fresh plan.',
|
|
246
|
+
],
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const runnerDescription = projectRunner ? 'repository test wrapper' : `installed ${framework} runner`
|
|
250
|
+
const browserRequired = framework === 'cypress' ||
|
|
251
|
+
framework === 'playwright' ||
|
|
252
|
+
(framework === 'vitest' && runnerContract.runnerArgs.includes('--browser'))
|
|
157
253
|
|
|
158
254
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
255
|
+
...base,
|
|
256
|
+
browserRequired,
|
|
257
|
+
language: /\.[cm]?tsx?$/.test(candidate.path) ? 'typescript' : 'javascript',
|
|
258
|
+
localSocketRequired: candidate.requiresLocalSocket,
|
|
163
259
|
status: 'runnable',
|
|
164
|
-
supportLevel: '
|
|
165
|
-
project:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
preflight: { status: 'pending', maxTestCount: 50 },
|
|
169
|
-
ciWiring: {
|
|
170
|
-
status: 'unknown',
|
|
171
|
-
replayability: 'not_replayable',
|
|
172
|
-
replayBlocker: 'CI command selection has not been completed. Inspect the discovered CI configuration and ' +
|
|
173
|
-
'replace this with a concrete technical blocker only when the selected test command cannot be replayed.',
|
|
174
|
-
diagnosis: 'Select one replayable CI test step and record its exact command and environment before live CI ' +
|
|
175
|
-
'wiring validation.',
|
|
176
|
-
initialization: {
|
|
177
|
-
status: 'unknown',
|
|
178
|
-
evidence: [],
|
|
179
|
-
},
|
|
260
|
+
supportLevel: 'validator_direct_runner',
|
|
261
|
+
project: {
|
|
262
|
+
...base.project,
|
|
263
|
+
configFiles,
|
|
180
264
|
},
|
|
265
|
+
validation: {
|
|
266
|
+
environment: runnerContract.environment,
|
|
267
|
+
...(runnerContract.omittedOptions?.length > 0
|
|
268
|
+
? { omittedRunnerOptions: runnerContract.omittedOptions }
|
|
269
|
+
: {}),
|
|
270
|
+
requiredEnvVars: [],
|
|
271
|
+
runner,
|
|
272
|
+
runnerArgs: runnerContract.runnerArgs,
|
|
273
|
+
selectorScope: projectRunner ? 'instrumented_event_identity' : 'bounded_direct_runner',
|
|
274
|
+
testFile: candidate.path,
|
|
275
|
+
timeoutMs: browserRequired ? 300_000 : 180_000,
|
|
276
|
+
},
|
|
277
|
+
preflight: { status: 'pending' },
|
|
181
278
|
generatedTestStrategy,
|
|
182
279
|
notes: [
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
280
|
+
`Basic Reporting will invoke the ${runnerDescription} ` +
|
|
281
|
+
'directly for ' +
|
|
282
|
+
`${path.relative(repositoryRoot, candidate.path)}.`,
|
|
283
|
+
'The validator retained only allowlisted runner configuration from the detected package script.',
|
|
284
|
+
...(projectRunner
|
|
285
|
+
? [
|
|
286
|
+
'Basic Reporting will remain incomplete unless captured test events identify only the approved ' +
|
|
287
|
+
'representative file.',
|
|
288
|
+
]
|
|
289
|
+
: []),
|
|
290
|
+
...(candidate.requiresLocalSocket
|
|
291
|
+
? [
|
|
292
|
+
'The selected test appears to require localhost. A restricted sandbox may leave local validation ' +
|
|
293
|
+
'incomplete.',
|
|
294
|
+
]
|
|
295
|
+
: []),
|
|
190
296
|
],
|
|
191
297
|
}
|
|
192
298
|
}
|
|
193
299
|
|
|
194
|
-
function
|
|
300
|
+
function getImplicitConfigFiles (framework, projectRoot, repositoryRoot) {
|
|
301
|
+
const files = []
|
|
302
|
+
for (const basename of IMPLICIT_CONFIG_FILENAMES[framework] || []) {
|
|
303
|
+
const filename = path.join(projectRoot, basename)
|
|
304
|
+
try {
|
|
305
|
+
const stat = fs.lstatSync(filename)
|
|
306
|
+
const physical = fs.realpathSync(filename)
|
|
307
|
+
if (stat.isFile() && !stat.isSymbolicLink() &&
|
|
308
|
+
fs.statSync(physical).isFile() &&
|
|
309
|
+
isPathInside(fs.realpathSync(repositoryRoot), physical)) files.push(physical)
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
312
|
+
return files
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Builds a diagnostic-only supported framework entry.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} repositoryRoot repository root
|
|
319
|
+
* @param {object} detection supported framework detection
|
|
320
|
+
* @param {object} ciDiscovery bounded CI discovery result
|
|
321
|
+
* @returns {object} framework manifest entry
|
|
322
|
+
*/
|
|
323
|
+
function buildDetectedOnlyFramework (repositoryRoot, detection, ciDiscovery) {
|
|
324
|
+
const packageJson = getDetectionPackageJson(repositoryRoot, detection.locations?.[0])
|
|
325
|
+
const projectRoot = path.dirname(packageJson.path)
|
|
326
|
+
const version = detection.supportedVersion?.version ||
|
|
327
|
+
detection.versionDetections?.[0]?.version ||
|
|
328
|
+
detection.versionDetections?.[0]?.rawVersion
|
|
329
|
+
return {
|
|
330
|
+
...getFrameworkBase({
|
|
331
|
+
ciDiscovery,
|
|
332
|
+
detection: { ...detection, version },
|
|
333
|
+
framework: detection.id,
|
|
334
|
+
packageJson,
|
|
335
|
+
projectRoot,
|
|
336
|
+
repositoryRoot,
|
|
337
|
+
}),
|
|
338
|
+
status: 'detected_not_runnable',
|
|
339
|
+
notes: [
|
|
340
|
+
detection.supportedVersion
|
|
341
|
+
? `A supported ${detection.name} installation was detected, but no project-local validation target was found.`
|
|
342
|
+
: `${detection.name} was detected, but no supported installed version was confirmed.`,
|
|
343
|
+
],
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Builds an unsupported framework entry.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} repositoryRoot repository root
|
|
351
|
+
* @param {object} detection unsupported framework detection
|
|
352
|
+
* @param {object} ciDiscovery bounded CI discovery result
|
|
353
|
+
* @returns {object} framework manifest entry
|
|
354
|
+
*/
|
|
355
|
+
function buildUnsupportedFramework (repositoryRoot, detection, ciDiscovery) {
|
|
356
|
+
const packageJson = getDetectionPackageJson(repositoryRoot, detection.locations?.[0])
|
|
357
|
+
const projectRoot = path.dirname(packageJson.path)
|
|
358
|
+
const framework = detection.id === 'node-test' ? 'node:test' : detection.id
|
|
195
359
|
return {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
360
|
+
...getFrameworkBase({
|
|
361
|
+
ciDiscovery,
|
|
362
|
+
detection,
|
|
363
|
+
framework,
|
|
364
|
+
packageJson,
|
|
365
|
+
projectRoot,
|
|
366
|
+
repositoryRoot,
|
|
367
|
+
}),
|
|
368
|
+
status: 'unsupported_by_validator',
|
|
369
|
+
notes: [`${detection.name} is not supported by this Test Optimization validator.`],
|
|
201
370
|
}
|
|
202
371
|
}
|
|
203
372
|
|
|
204
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Returns fields shared by runnable and diagnostic framework entries.
|
|
375
|
+
*
|
|
376
|
+
* @param {object} input framework inputs
|
|
377
|
+
* @param {object} input.ciDiscovery bounded CI discovery
|
|
378
|
+
* @param {object} input.detection framework detection
|
|
379
|
+
* @param {string} input.framework framework name
|
|
380
|
+
* @param {object} input.packageJson package metadata
|
|
381
|
+
* @param {string} input.projectRoot project root
|
|
382
|
+
* @param {string} input.repositoryRoot repository root
|
|
383
|
+
* @returns {object} shared framework fields
|
|
384
|
+
*/
|
|
385
|
+
function getFrameworkBase ({
|
|
386
|
+
ciDiscovery,
|
|
387
|
+
detection,
|
|
205
388
|
framework,
|
|
389
|
+
packageJson,
|
|
206
390
|
projectRoot,
|
|
207
391
|
repositoryRoot,
|
|
208
|
-
runner,
|
|
209
|
-
scriptName,
|
|
210
|
-
preserveProjectWrapper,
|
|
211
392
|
}) {
|
|
212
|
-
const packageManager = preserveProjectWrapper ? detectPackageManager(repositoryRoot) : undefined
|
|
213
|
-
const argv = preserveProjectWrapper
|
|
214
|
-
? getPackageScriptArgv(packageManager, scriptName, repositoryRoot)
|
|
215
|
-
: getDirectRunnerArgv(framework, runner)
|
|
216
393
|
return {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
394
|
+
id: `${framework}:${getProjectId(packageJson.json, projectRoot, repositoryRoot)}`,
|
|
395
|
+
framework,
|
|
396
|
+
frameworkVersion: detection.version || detection.supportedVersion?.version || null,
|
|
397
|
+
language: 'unknown',
|
|
398
|
+
supportLevel: 'detected_only',
|
|
399
|
+
project: {
|
|
400
|
+
configFiles: [],
|
|
401
|
+
name: packageJson.json.name || path.basename(projectRoot) || 'root',
|
|
402
|
+
packageJson: packageJson.path,
|
|
403
|
+
root: projectRoot,
|
|
404
|
+
},
|
|
405
|
+
ciWiring: buildCiWiring(ciDiscovery),
|
|
226
406
|
}
|
|
227
407
|
}
|
|
228
408
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Builds inert CI review fields. The agent may populate only this evidence section.
|
|
411
|
+
*
|
|
412
|
+
* @param {object} ciDiscovery bounded CI discovery result
|
|
413
|
+
* @returns {object} CI evidence scaffold
|
|
414
|
+
*/
|
|
415
|
+
function buildCiWiring (ciDiscovery) {
|
|
416
|
+
const unresolved = ciDiscovery.reviewRequired
|
|
417
|
+
? ['The exact CI test job and its effective environment have not been reviewed.']
|
|
418
|
+
: ['No supported CI configuration file was found by bounded discovery.']
|
|
235
419
|
return {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
path: definition.file,
|
|
246
|
-
role: 'test',
|
|
247
|
-
contentLines: definition.content.split('\n'),
|
|
248
|
-
})),
|
|
249
|
-
scenarios: definitions.map(definition => ({
|
|
250
|
-
id: definition.id,
|
|
251
|
-
purpose: definition.purpose,
|
|
252
|
-
runCommand: baseCommand
|
|
253
|
-
? buildFocusedCommand(baseCommand, framework, definition.file, true, true, moduleSystem)
|
|
254
|
-
: buildGeneratedRunCommand(framework, projectRoot, definition.file, runner, moduleSystem),
|
|
255
|
-
expectedWithoutDatadog: {
|
|
256
|
-
exitCode: definition.id === 'atr-fail-once' ? 1 : 0,
|
|
257
|
-
observedTestCount: 1,
|
|
258
|
-
},
|
|
259
|
-
testIdentities: [{
|
|
260
|
-
suite: null,
|
|
261
|
-
name: definition.testName,
|
|
262
|
-
file: definition.file,
|
|
263
|
-
parameters: null,
|
|
264
|
-
}],
|
|
265
|
-
})),
|
|
266
|
-
cleanupPaths: [
|
|
267
|
-
...definitions.map(definition => definition.file),
|
|
268
|
-
path.join(path.dirname(definitions.find(definition => definition.id === 'atr-fail-once').file),
|
|
269
|
-
'.dd-test-optimization-validation-atr-state'),
|
|
270
|
-
],
|
|
420
|
+
command: null,
|
|
421
|
+
configFile: null,
|
|
422
|
+
initialization: { evidence: [], status: 'unknown' },
|
|
423
|
+
job: null,
|
|
424
|
+
reviewComplete: false,
|
|
425
|
+
step: null,
|
|
426
|
+
transport: { evidence: [], mode: 'unknown' },
|
|
427
|
+
unresolved,
|
|
428
|
+
workingDirectory: null,
|
|
271
429
|
}
|
|
272
430
|
}
|
|
273
431
|
|
|
274
|
-
function getGeneratedModuleSystem (framework, fileExtension, packageType) {
|
|
275
|
-
if (/\.(?:cjs|cts)$/.test(fileExtension)) return 'commonjs'
|
|
276
|
-
if (framework === 'vitest' || /\.(?:mjs|mts)$/.test(fileExtension)) return 'esm'
|
|
277
|
-
return packageType === 'module' ? 'esm' : 'commonjs'
|
|
278
|
-
}
|
|
279
|
-
|
|
280
432
|
/**
|
|
281
|
-
*
|
|
433
|
+
* Builds canonical generated test data without embedding executable commands.
|
|
282
434
|
*
|
|
283
|
-
* @param {
|
|
284
|
-
* @param {string}
|
|
285
|
-
* @param {string
|
|
286
|
-
* @
|
|
435
|
+
* @param {object} input generated-test inputs
|
|
436
|
+
* @param {string} input.framework framework name
|
|
437
|
+
* @param {string} input.projectRoot project root
|
|
438
|
+
* @param {string} input.representative representative test file
|
|
439
|
+
* @returns {object} generated test strategy
|
|
287
440
|
*/
|
|
288
|
-
function
|
|
289
|
-
const
|
|
290
|
-
|
|
441
|
+
function buildGeneratedTestStrategy ({ framework, projectRoot, representative }) {
|
|
442
|
+
const convention = getGeneratedTestConvention(framework, representative, projectRoot)
|
|
443
|
+
const testDirectory = convention.testDirectory
|
|
444
|
+
const extension = convention.fileExtension
|
|
445
|
+
const moduleSystem = getModuleSystem(representative, projectRoot)
|
|
446
|
+
const files = []
|
|
447
|
+
const scenarios = []
|
|
448
|
+
const cleanupPaths = []
|
|
291
449
|
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
450
|
+
for (const [id, scenario] of Object.entries(GENERATED_SCENARIOS)) {
|
|
451
|
+
const prefix = `dd-test-optimization-validation-${framework}-${id}`
|
|
452
|
+
const filename = convention.exactFilename
|
|
453
|
+
? path.join(testDirectory, prefix, convention.exactFilename)
|
|
454
|
+
: path.join(testDirectory, `${prefix}${extension}`)
|
|
455
|
+
const content = getGeneratedTestContent({
|
|
456
|
+
framework,
|
|
457
|
+
moduleSystem,
|
|
458
|
+
scenarioId: id,
|
|
459
|
+
stateFile: ['cucumber', 'cypress', 'playwright'].includes(framework)
|
|
460
|
+
? undefined
|
|
461
|
+
: getGeneratedRetryStatePath(framework, filename),
|
|
462
|
+
})
|
|
463
|
+
files.push({ path: filename, contentLines: content.split('\n') })
|
|
464
|
+
cleanupPaths.push(filename)
|
|
465
|
+
scenarios.push({
|
|
466
|
+
id,
|
|
467
|
+
expectedWithoutDatadog: scenario.expectedWithoutDatadog,
|
|
468
|
+
testIdentities: [{
|
|
469
|
+
file: filename,
|
|
470
|
+
name: scenario.testName,
|
|
471
|
+
suite: scenario.suiteName,
|
|
472
|
+
}],
|
|
473
|
+
})
|
|
297
474
|
}
|
|
298
475
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (!representative) {
|
|
304
|
-
return {
|
|
305
|
-
exactFilename: undefined,
|
|
306
|
-
fileExtension: '.test.js',
|
|
307
|
-
testDirectory: path.join(projectRoot, 'test'),
|
|
308
|
-
}
|
|
476
|
+
if (framework === 'cucumber') {
|
|
477
|
+
const stepsFile = cucumber.getGeneratedStepsPath(testDirectory)
|
|
478
|
+
files.push({ path: stepsFile, contentLines: cucumber.getGeneratedStepsContent().split('\n') })
|
|
479
|
+
cleanupPaths.push(stepsFile)
|
|
309
480
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
testDirectory: representativeDirectory === projectRoot
|
|
318
|
-
? projectRoot
|
|
319
|
-
: path.dirname(representativeDirectory),
|
|
320
|
-
}
|
|
481
|
+
if (framework === 'playwright') {
|
|
482
|
+
const configFile = playwright.getGeneratedConfigPath(testDirectory)
|
|
483
|
+
files.push({ path: configFile, contentLines: playwright.getGeneratedConfigContent().split('\n') })
|
|
484
|
+
cleanupPaths.push(configFile)
|
|
485
|
+
}
|
|
486
|
+
if (['jest', 'mocha', 'vitest'].includes(framework)) {
|
|
487
|
+
cleanupPaths.push(getGeneratedRetryStatePath(framework, scenarios[1].testIdentities[0].file))
|
|
321
488
|
}
|
|
322
489
|
|
|
323
490
|
return {
|
|
324
|
-
|
|
325
|
-
fileExtension:
|
|
326
|
-
|
|
491
|
+
cleanupPaths,
|
|
492
|
+
fileExtension: extension,
|
|
493
|
+
files,
|
|
494
|
+
moduleSystem,
|
|
495
|
+
reason: 'Validator-owned direct-runner recipes.',
|
|
496
|
+
scenarios,
|
|
497
|
+
status: 'planned',
|
|
498
|
+
testDirectory,
|
|
327
499
|
}
|
|
328
500
|
}
|
|
329
501
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
502
|
+
/**
|
|
503
|
+
* Selects one representative framework-owned test file.
|
|
504
|
+
*
|
|
505
|
+
* @param {string[]} files bounded project files
|
|
506
|
+
* @param {string} framework framework name
|
|
507
|
+
* @param {string} projectRoot project root
|
|
508
|
+
* @param {string} packageName project package name
|
|
509
|
+
* @param {boolean} allowDirectoryConvention whether a literal runner selector chose this root
|
|
510
|
+
* @returns {{path: string, requiresExternalService: boolean, requiresLocalSocket: boolean}|undefined} selected test
|
|
511
|
+
*/
|
|
512
|
+
function selectRepresentativeTest (files, framework, projectRoot, packageName, allowDirectoryConvention) {
|
|
513
|
+
const candidates = []
|
|
514
|
+
for (const filename of files) {
|
|
515
|
+
const source = readText(filename)
|
|
516
|
+
if (source === undefined ||
|
|
517
|
+
!isTestFile(filename, source, framework, projectRoot, allowDirectoryConvention) ||
|
|
518
|
+
hasConflictingFramework(source, framework)) continue
|
|
519
|
+
if (framework === 'cucumber'
|
|
520
|
+
? cucumber.getScenarioCount(source) === 0
|
|
521
|
+
: getStaticTestCount(source) === 0) continue
|
|
522
|
+
if (!hasFrameworkOwnership(filename, source, framework, packageName)) continue
|
|
523
|
+
candidates.push({
|
|
524
|
+
path: filename,
|
|
525
|
+
rank: getTestRank(filename, source, projectRoot),
|
|
526
|
+
requiresExternalService: framework === 'cypress' && CYPRESS_LOCAL_ORIGIN_PATTERN.test(source),
|
|
527
|
+
requiresLocalSocket: LOCAL_SOCKET_PATTERN.test(source),
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
candidates.sort((left, right) => {
|
|
531
|
+
return Number(left.requiresExternalService) - Number(right.requiresExternalService) ||
|
|
532
|
+
left.rank - right.rank ||
|
|
533
|
+
left.path.localeCompare(right.path)
|
|
534
|
+
})
|
|
535
|
+
return candidates[0]
|
|
536
|
+
}
|
|
339
537
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
538
|
+
/**
|
|
539
|
+
* Reports whether a file follows a selected framework's test convention.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} filename candidate file
|
|
542
|
+
* @param {string} source candidate source
|
|
543
|
+
* @param {string} framework framework name
|
|
544
|
+
* @param {string} projectRoot project root
|
|
545
|
+
* @param {boolean} allowDirectoryConvention whether a literal runner selector chose this root
|
|
546
|
+
* @returns {boolean} whether the file is a candidate
|
|
547
|
+
*/
|
|
548
|
+
function isTestFile (filename, source, framework, projectRoot, allowDirectoryConvention) {
|
|
549
|
+
const basename = path.basename(filename)
|
|
550
|
+
const normalized = filename.replaceAll('\\', '/')
|
|
551
|
+
if (normalized.includes('/dd-test-optimization-validation-')) return false
|
|
552
|
+
if (TYPE_ONLY_TEST_PATTERN.test(basename) || TYPE_ONLY_DIRECTORY_PATTERN.test(normalized)) return false
|
|
553
|
+
if (framework === 'cucumber') return cucumber.isTestFile(filename)
|
|
554
|
+
if (framework === 'cypress') return cypress.isTestFile(basename, path.dirname(filename), projectRoot)
|
|
555
|
+
if (framework === 'playwright') return playwright.isTestFile(basename, path.dirname(filename), projectRoot)
|
|
556
|
+
if (TEST_FILE_PATTERN.test(basename)) return true
|
|
557
|
+
const directories = [
|
|
558
|
+
path.basename(projectRoot),
|
|
559
|
+
...path.relative(projectRoot, path.dirname(filename)).split(path.sep),
|
|
560
|
+
]
|
|
561
|
+
const inTestDirectory = directories.some(directory => {
|
|
562
|
+
return ['__tests__', 'spec', 'test', 'tests'].includes(directory)
|
|
350
563
|
})
|
|
564
|
+
if (BARE_TEST_FILE_PATTERN.test(basename)) {
|
|
565
|
+
return inTestDirectory || hasExplicitFrameworkImport(source, framework)
|
|
566
|
+
}
|
|
567
|
+
return allowDirectoryConvention && inTestDirectory && /\.[cm]?[jt]sx?$/.test(basename)
|
|
351
568
|
}
|
|
352
569
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
570
|
+
/**
|
|
571
|
+
* Reports whether an unconventional test file imports the selected framework directly.
|
|
572
|
+
*
|
|
573
|
+
* @param {string} source candidate source
|
|
574
|
+
* @param {string} framework framework name
|
|
575
|
+
* @returns {boolean} whether ownership is explicit
|
|
576
|
+
*/
|
|
577
|
+
function hasExplicitFrameworkImport (source, framework) {
|
|
578
|
+
if (framework === 'vitest') {
|
|
579
|
+
return /(?:from\s+|require\s*\(\s*)['"]vitest['"]/.test(source)
|
|
357
580
|
}
|
|
358
|
-
if (framework === '
|
|
359
|
-
|
|
360
|
-
? "import assert from 'node:assert/strict'"
|
|
361
|
-
: "const assert = require('node:assert/strict')")
|
|
362
|
-
}
|
|
363
|
-
if (definition.id === 'atr-fail-once') {
|
|
364
|
-
if (moduleSystem === 'esm') {
|
|
365
|
-
imports.push(
|
|
366
|
-
"import { existsSync, writeFileSync } from 'node:fs'",
|
|
367
|
-
"import { dirname, join } from 'node:path'",
|
|
368
|
-
"import { fileURLToPath } from 'node:url'"
|
|
369
|
-
)
|
|
370
|
-
} else {
|
|
371
|
-
imports.push("const fs = require('node:fs')", "const path = require('node:path')")
|
|
372
|
-
}
|
|
581
|
+
if (framework === 'jest') {
|
|
582
|
+
return /(?:from\s+|require\s*\(\s*)['"]@jest\/globals['"]/.test(source)
|
|
373
583
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const body = definition.id === 'atr-fail-once'
|
|
377
|
-
? getAtrBody({ moduleSystem, stateFileExpression, assertion })
|
|
378
|
-
: ` ${assertion}`
|
|
379
|
-
|
|
380
|
-
return [
|
|
381
|
-
...imports,
|
|
382
|
-
imports.length > 0 ? '' : undefined,
|
|
383
|
-
"describe('dd-test-optimization-validation', () => {",
|
|
384
|
-
` ${testFunction}('${definition.testName}', () => {`,
|
|
385
|
-
body,
|
|
386
|
-
' })',
|
|
387
|
-
'})',
|
|
388
|
-
].filter(line => line !== undefined).join('\n')
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function getAtrBody ({ moduleSystem, stateFileExpression, assertion }) {
|
|
392
|
-
const exists = moduleSystem === 'esm' ? 'existsSync' : 'fs.existsSync'
|
|
393
|
-
const write = moduleSystem === 'esm' ? 'writeFileSync' : 'fs.writeFileSync'
|
|
394
|
-
return [
|
|
395
|
-
` const stateFile = ${stateFileExpression}`,
|
|
396
|
-
` if (!${exists}(stateFile)) {`,
|
|
397
|
-
` ${write}(stateFile, 'failed-once')`,
|
|
398
|
-
" throw new Error('dd-test-optimization-validation atr first failure')",
|
|
399
|
-
' }',
|
|
400
|
-
` ${assertion}`,
|
|
401
|
-
].join('\n')
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function buildGeneratedRunCommand (framework, projectRoot, filename, runner, moduleSystem) {
|
|
405
|
-
const args = {
|
|
406
|
-
jest: ['--runTestsByPath', filename, '--runInBand', '--silent', '--no-watchman'],
|
|
407
|
-
mocha: ['--reporter', 'spec', filename],
|
|
408
|
-
vitest: ['run', filename, ...(moduleSystem === 'commonjs' ? ['--globals'] : [])],
|
|
409
|
-
}[framework]
|
|
410
|
-
return {
|
|
411
|
-
cwd: projectRoot,
|
|
412
|
-
argv: [process.execPath, runner, ...args],
|
|
413
|
-
env: {},
|
|
414
|
-
requiredEnvVars: [],
|
|
415
|
-
timeoutMs: 300_000,
|
|
416
|
-
usesShell: false,
|
|
584
|
+
if (framework === 'mocha') {
|
|
585
|
+
return /(?:from\s+|require\s*\(\s*)['"]mocha['"]/.test(source)
|
|
417
586
|
}
|
|
587
|
+
return false
|
|
418
588
|
}
|
|
419
589
|
|
|
420
590
|
/**
|
|
421
|
-
*
|
|
591
|
+
* Conservatively associates a test source with one framework.
|
|
422
592
|
*
|
|
423
|
-
* @param {
|
|
424
|
-
* @param {string}
|
|
425
|
-
* @param {string}
|
|
426
|
-
* @param {
|
|
427
|
-
* @
|
|
428
|
-
* @param {string} [moduleSystem] generated test module system
|
|
429
|
-
* @returns {object} focused project command
|
|
593
|
+
* @param {string} filename candidate filename
|
|
594
|
+
* @param {string} source file source
|
|
595
|
+
* @param {string} framework framework name
|
|
596
|
+
* @param {string} packageName project package name
|
|
597
|
+
* @returns {boolean} whether ownership is plausible
|
|
430
598
|
*/
|
|
431
|
-
function
|
|
432
|
-
|
|
433
|
-
framework
|
|
434
|
-
filename
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
) {
|
|
439
|
-
const argv = [...baseCommand.argv]
|
|
440
|
-
if (packageScript && path.basename(argv[0]).toLowerCase() === 'npm') argv.push('--')
|
|
441
|
-
argv.push(...getFocusedTestArgs(framework, filename, preserveDefaultReporter, moduleSystem))
|
|
442
|
-
|
|
443
|
-
return {
|
|
444
|
-
...baseCommand,
|
|
445
|
-
description: `${baseCommand.description} targeting ${path.basename(filename)}`,
|
|
446
|
-
argv,
|
|
599
|
+
function hasFrameworkOwnership (filename, source, framework, packageName) {
|
|
600
|
+
const normalized = filename.replaceAll('\\', '/').toLowerCase()
|
|
601
|
+
if (framework === 'cucumber') return /^\s*(?:Feature|Rule):/m.test(source)
|
|
602
|
+
if (framework === 'cypress') return /\.cy\./.test(filename) || normalized.includes('/cypress/')
|
|
603
|
+
if (framework === 'playwright') {
|
|
604
|
+
return /@playwright\/test/.test(source) || normalized.includes('/playwright/') ||
|
|
605
|
+
normalized.includes('/e2e/')
|
|
447
606
|
}
|
|
607
|
+
if (framework === 'vitest') return /\bvitest\b/.test(source) || /\b(?:describe|it|test)\s*\(/.test(source)
|
|
608
|
+
if (framework === 'jest') return /\bjest\b/.test(source) || /\b(?:describe|it|test)\s*\(/.test(source)
|
|
609
|
+
if (framework === 'mocha') {
|
|
610
|
+
return /\bmocha\b/.test(source) || /\bdescribe\s*\(/.test(source) ||
|
|
611
|
+
packageName === 'mocha'
|
|
612
|
+
}
|
|
613
|
+
return false
|
|
448
614
|
}
|
|
449
615
|
|
|
450
616
|
/**
|
|
451
|
-
*
|
|
617
|
+
* Rejects files visibly owned by another supported runner.
|
|
452
618
|
*
|
|
453
|
-
* @param {string}
|
|
454
|
-
* @param {string}
|
|
455
|
-
* @
|
|
456
|
-
* @param {string} [moduleSystem] generated test module system
|
|
457
|
-
* @returns {string[]} focused test arguments
|
|
619
|
+
* @param {string} source candidate source
|
|
620
|
+
* @param {string} framework selected framework
|
|
621
|
+
* @returns {boolean} whether source ownership conflicts
|
|
458
622
|
*/
|
|
459
|
-
function
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
'--no-watchman',
|
|
467
|
-
]
|
|
623
|
+
function hasConflictingFramework (source, framework) {
|
|
624
|
+
const markers = {
|
|
625
|
+
cypress: /\bcy\.(?:visit|request|get)\b|from\s+['"]cypress/,
|
|
626
|
+
jest: /@jest\/globals|\bjest\.(?:mock|fn|spyOn)\b/,
|
|
627
|
+
'node:test': /(?:from\s+|require\s*\(\s*)['"]node:test['"]/,
|
|
628
|
+
playwright: /@playwright\/test/,
|
|
629
|
+
vitest: /from\s+['"]vitest['"]|require\s*\(\s*['"]vitest['"]\s*\)|\bvi\./,
|
|
468
630
|
}
|
|
469
|
-
return [
|
|
631
|
+
return Object.entries(markers).some(([name, pattern]) => name !== framework && pattern.test(source))
|
|
470
632
|
}
|
|
471
633
|
|
|
472
634
|
/**
|
|
473
|
-
*
|
|
474
|
-
* declares the dependency.
|
|
635
|
+
* Ranks simpler unit tests ahead of integration and service-dependent tests.
|
|
475
636
|
*
|
|
476
|
-
* @param {string}
|
|
477
|
-
* @param {string}
|
|
478
|
-
* @
|
|
637
|
+
* @param {string} filename candidate filename
|
|
638
|
+
* @param {string} source candidate source
|
|
639
|
+
* @param {string} projectRoot project root
|
|
640
|
+
* @returns {number} lower-is-better rank
|
|
479
641
|
*/
|
|
480
|
-
function
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
642
|
+
function getTestRank (filename, source, projectRoot) {
|
|
643
|
+
const relative = path.relative(projectRoot, filename).replaceAll('\\', '/').toLowerCase()
|
|
644
|
+
let rank = relative.split('/').length
|
|
645
|
+
if (/(?:^|\/)(?:test|tests|__tests__|spec)\//.test(relative)) rank -= 20
|
|
646
|
+
if (/(?:^|[._/-])unit(?:[._/-]|$)/.test(relative)) rank -= 15
|
|
647
|
+
if (/(?:e2e|integration|acceptance|browser|conformance|fixtures?)/.test(relative)) rank += 20
|
|
648
|
+
if (LOCAL_SOCKET_PATTERN.test(source)) rank += 30
|
|
649
|
+
rank += Math.min(getStaticTestCount(source), 50)
|
|
650
|
+
return rank
|
|
484
651
|
}
|
|
485
652
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
653
|
+
/**
|
|
654
|
+
* Counts static test declarations for preferring small representative files.
|
|
655
|
+
*
|
|
656
|
+
* @param {string} source test source
|
|
657
|
+
* @returns {number} approximate test declaration count
|
|
658
|
+
*/
|
|
659
|
+
function getStaticTestCount (source) {
|
|
660
|
+
return [...source.matchAll(/\b(?:it|test)((?:\.[A-Za-z]+)*)\s*\(/g)]
|
|
661
|
+
.filter(match => !/\.(?:skip|todo)\b/.test(match[1]))
|
|
662
|
+
.length
|
|
492
663
|
}
|
|
493
664
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
665
|
+
/**
|
|
666
|
+
* Narrows a repository root to the conventional package matching its identity.
|
|
667
|
+
*
|
|
668
|
+
* @param {string} projectRoot detected project root
|
|
669
|
+
* @param {string} repositoryRoot repository root
|
|
670
|
+
* @returns {string} preferred representative root
|
|
671
|
+
*/
|
|
672
|
+
function findPreferredRepresentativeRoot (projectRoot, repositoryRoot) {
|
|
673
|
+
if (path.resolve(projectRoot) !== path.resolve(repositoryRoot)) return projectRoot
|
|
674
|
+
const repositoryName = normalizeProjectIdentity(path.basename(repositoryRoot))
|
|
675
|
+
|
|
676
|
+
for (const containerName of ['packages', 'pkgs', 'modules']) {
|
|
677
|
+
const container = path.join(repositoryRoot, containerName)
|
|
678
|
+
for (const entry of readDirectoryEntries(container)) {
|
|
679
|
+
if (!entry.isDirectory()) continue
|
|
680
|
+
const candidate = path.join(container, entry.name)
|
|
681
|
+
const packageJson = readJson(path.join(candidate, 'package.json'))
|
|
682
|
+
if (normalizeProjectIdentity(packageJson?.name || entry.name) === repositoryName) return candidate
|
|
683
|
+
}
|
|
498
684
|
}
|
|
499
|
-
return
|
|
685
|
+
return projectRoot
|
|
500
686
|
}
|
|
501
687
|
|
|
502
688
|
/**
|
|
503
|
-
*
|
|
689
|
+
* Normalizes repository and package names for exact identity comparison.
|
|
504
690
|
*
|
|
505
|
-
* @param {
|
|
506
|
-
* @
|
|
507
|
-
* @returns {string|undefined} package script name
|
|
691
|
+
* @param {string} value package identity
|
|
692
|
+
* @returns {string} normalized identity
|
|
508
693
|
*/
|
|
509
|
-
function
|
|
510
|
-
|
|
694
|
+
function normalizeProjectIdentity (value) {
|
|
695
|
+
const unscoped = String(value || '').toLowerCase().replace(/^@[^/]+\//, '')
|
|
696
|
+
return unscoped.replaceAll(/[^a-z0-9]+/g, '').replace(/js$/, '')
|
|
511
697
|
}
|
|
512
698
|
|
|
513
699
|
/**
|
|
514
|
-
*
|
|
700
|
+
* Collects bounded regular project files without following symbolic links.
|
|
515
701
|
*
|
|
516
|
-
* @param {string}
|
|
517
|
-
* @
|
|
518
|
-
* @returns {boolean} whether the command starts with the framework binary
|
|
702
|
+
* @param {string} root project root
|
|
703
|
+
* @returns {string[]} absolute files
|
|
519
704
|
*/
|
|
520
|
-
function
|
|
521
|
-
const
|
|
522
|
-
const
|
|
523
|
-
if (executable !== framework) return false
|
|
524
|
-
return tokens.length === 1 || framework === 'vitest' && tokens.length === 2 && tokens[1] === 'run'
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function getDirectRunnerArgv (framework, runner) {
|
|
528
|
-
return [process.execPath, runner, ...(framework === 'vitest' ? ['run'] : [])]
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function findRepresentativeTestFile (root) {
|
|
532
|
-
const stack = [root]
|
|
533
|
-
const candidates = []
|
|
534
|
-
const packageRanks = new Map()
|
|
705
|
+
function collectProjectFiles (root) {
|
|
706
|
+
const files = []
|
|
707
|
+
const pending = [root]
|
|
535
708
|
let visited = 0
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
.sort((left, right) => left.name.localeCompare(right.name))
|
|
542
|
-
} catch {
|
|
543
|
-
continue
|
|
544
|
-
}
|
|
545
|
-
for (const entry of entries) {
|
|
546
|
-
if (['.git', 'node_modules', 'coverage', 'dist', 'build'].includes(entry.name)) continue
|
|
709
|
+
|
|
710
|
+
while (pending.length > 0 && visited < MAX_DISCOVERY_ENTRIES) {
|
|
711
|
+
const directory = pending.shift()
|
|
712
|
+
for (const entry of readDirectoryEntries(directory)) {
|
|
713
|
+
if (++visited > MAX_DISCOVERY_ENTRIES) break
|
|
547
714
|
const filename = path.join(directory, entry.name)
|
|
715
|
+
if (entry.isSymbolicLink()) continue
|
|
548
716
|
if (entry.isDirectory()) {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
visited++
|
|
553
|
-
const inTestsDirectory = path.relative(root, directory).split(path.sep).includes('__tests__')
|
|
554
|
-
if (/^(?:test\.(?:[cm]?[jt]s|[jt]sx)|.+[.-](?:test|spec)\.(?:[cm]?[jt]s|[jt]sx))$/.test(entry.name) ||
|
|
555
|
-
(inTestsDirectory && /\.(?:[cm]?[jt]s|[jt]sx)$/.test(entry.name))) {
|
|
556
|
-
candidates.push(filename)
|
|
717
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(filename)
|
|
718
|
+
} else if (entry.isFile()) {
|
|
719
|
+
files.push(filename)
|
|
557
720
|
}
|
|
558
721
|
}
|
|
559
722
|
}
|
|
723
|
+
return files
|
|
724
|
+
}
|
|
560
725
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
726
|
+
/**
|
|
727
|
+
* Resolves a framework package executable only inside the repository.
|
|
728
|
+
*
|
|
729
|
+
* @param {string} framework framework name
|
|
730
|
+
* @param {string} projectRoot project root
|
|
731
|
+
* @param {string} repositoryRoot repository root
|
|
732
|
+
* @returns {string|undefined} physical runner path
|
|
733
|
+
*/
|
|
734
|
+
function resolveRunner (framework, projectRoot, repositoryRoot) {
|
|
735
|
+
const packageName = getRunnerPackageName(framework)
|
|
736
|
+
const packageRoot = findPackageRoot(packageName, projectRoot, repositoryRoot)
|
|
737
|
+
if (!packageRoot) return
|
|
738
|
+
|
|
739
|
+
const packageJson = readJson(path.join(packageRoot, 'package.json'))
|
|
740
|
+
const binName = framework === 'cucumber' ? 'cucumber-js' : framework === 'playwright' ? 'playwright' : framework
|
|
741
|
+
const bin = typeof packageJson?.bin === 'string' ? packageJson.bin : packageJson?.bin?.[binName]
|
|
742
|
+
if (typeof bin !== 'string') return
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
const runner = fs.realpathSync(path.resolve(packageRoot, bin))
|
|
746
|
+
return isPathInside(fs.realpathSync(repositoryRoot), runner) && fs.statSync(runner).isFile()
|
|
747
|
+
? runner
|
|
748
|
+
: undefined
|
|
749
|
+
} catch {}
|
|
569
750
|
}
|
|
570
751
|
|
|
571
752
|
/**
|
|
572
|
-
*
|
|
753
|
+
* Finds an installed package or a framework source checkout.
|
|
573
754
|
*
|
|
574
|
-
* @param {string}
|
|
575
|
-
* @param {string}
|
|
576
|
-
* @
|
|
755
|
+
* @param {string} packageName runner package name
|
|
756
|
+
* @param {string} projectRoot project root
|
|
757
|
+
* @param {string} repositoryRoot repository root
|
|
758
|
+
* @returns {string|undefined} physical package root
|
|
577
759
|
*/
|
|
578
|
-
function
|
|
579
|
-
const
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
760
|
+
function findPackageRoot (packageName, projectRoot, repositoryRoot) {
|
|
761
|
+
const ownPackage = readJson(path.join(projectRoot, 'package.json'))
|
|
762
|
+
if (ownPackage?.name === packageName) return projectRoot
|
|
763
|
+
|
|
764
|
+
let directory = projectRoot
|
|
765
|
+
while (isPathInside(repositoryRoot, directory)) {
|
|
766
|
+
const candidate = path.join(directory, 'node_modules', ...packageName.split('/'))
|
|
767
|
+
try {
|
|
768
|
+
const physical = fs.realpathSync(candidate)
|
|
769
|
+
if (isPathInside(fs.realpathSync(repositoryRoot), physical)) return physical
|
|
770
|
+
} catch {}
|
|
771
|
+
if (directory === repositoryRoot) break
|
|
772
|
+
directory = path.dirname(directory)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Returns a runner package name.
|
|
778
|
+
*
|
|
779
|
+
* @param {string} framework framework name
|
|
780
|
+
* @returns {string} package name
|
|
781
|
+
*/
|
|
782
|
+
function getRunnerPackageName (framework) {
|
|
783
|
+
return {
|
|
784
|
+
cucumber: '@cucumber/cucumber',
|
|
785
|
+
playwright: '@playwright/test',
|
|
786
|
+
}[framework] || framework
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Returns a generated-test location that follows the representative discovery convention.
|
|
791
|
+
*
|
|
792
|
+
* @param {string} framework framework name
|
|
793
|
+
* @param {string} representative representative test
|
|
794
|
+
* @param {string} projectRoot detected project root
|
|
795
|
+
* @returns {{exactFilename: string|undefined, fileExtension: string, testDirectory: string}} convention
|
|
796
|
+
*/
|
|
797
|
+
function getGeneratedTestConvention (framework, representative, projectRoot) {
|
|
798
|
+
const basename = path.basename(representative)
|
|
799
|
+
if (/^test\.[cm]?[jt]s$/.test(basename)) {
|
|
800
|
+
const representativeDirectory = path.dirname(representative)
|
|
801
|
+
return {
|
|
802
|
+
exactFilename: basename,
|
|
803
|
+
fileExtension: path.extname(basename),
|
|
804
|
+
testDirectory: representativeDirectory === projectRoot
|
|
805
|
+
? projectRoot
|
|
806
|
+
: path.dirname(representativeDirectory),
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
exactFilename: undefined,
|
|
811
|
+
fileExtension: getTestExtension(framework, representative),
|
|
812
|
+
testDirectory: path.dirname(representative),
|
|
813
|
+
}
|
|
583
814
|
}
|
|
584
815
|
|
|
585
816
|
/**
|
|
586
|
-
*
|
|
817
|
+
* Returns the complete suffix needed for framework discovery.
|
|
587
818
|
*
|
|
588
|
-
* @param {string}
|
|
589
|
-
* @param {string}
|
|
590
|
-
* @returns {
|
|
819
|
+
* @param {string} framework framework name
|
|
820
|
+
* @param {string} representative representative test
|
|
821
|
+
* @returns {string} generated test suffix
|
|
591
822
|
*/
|
|
592
|
-
function
|
|
593
|
-
|
|
594
|
-
|
|
823
|
+
function getTestExtension (framework, representative) {
|
|
824
|
+
if (framework === 'cucumber') return '.feature'
|
|
825
|
+
if (framework === 'cypress') return cypress.getTestExtension(representative)
|
|
826
|
+
if (framework === 'playwright') return playwright.getTestExtension(representative)
|
|
827
|
+
const match = /((?:[.-](?:test|spec))\.[cm]?[jt]sx?)$/.exec(representative)
|
|
828
|
+
if (match) return match[1]
|
|
829
|
+
const moduleExtension = /\.((?:[cm]js|[cm]ts))$/.exec(representative)?.[1]
|
|
830
|
+
return moduleExtension ? `.test.${moduleExtension}` : '.test.js'
|
|
595
831
|
}
|
|
596
832
|
|
|
597
833
|
/**
|
|
598
|
-
*
|
|
834
|
+
* Determines the generated JavaScript module system.
|
|
599
835
|
*
|
|
600
|
-
* @param {string}
|
|
601
|
-
* @param {string}
|
|
602
|
-
* @
|
|
603
|
-
* @returns {number} independent test project rank
|
|
836
|
+
* @param {string} representative representative test
|
|
837
|
+
* @param {string} projectRoot project root
|
|
838
|
+
* @returns {'commonjs'|'esm'} module system
|
|
604
839
|
*/
|
|
605
|
-
function
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (cache.has(directory)) return cache.get(directory)
|
|
840
|
+
function getModuleSystem (representative, projectRoot) {
|
|
841
|
+
if (/\.(?:mjs|mts)$/.test(representative)) return 'esm'
|
|
842
|
+
if (/\.(?:cjs|cts)$/.test(representative)) return 'commonjs'
|
|
609
843
|
|
|
844
|
+
let directory = path.dirname(representative)
|
|
845
|
+
while (isPathInside(projectRoot, directory)) {
|
|
610
846
|
const packageJson = readJson(path.join(directory, 'package.json'))
|
|
611
|
-
if (packageJson)
|
|
612
|
-
|
|
613
|
-
cache.set(directory, rank)
|
|
614
|
-
return rank
|
|
615
|
-
}
|
|
847
|
+
if (packageJson) return packageJson.type === 'module' ? 'esm' : 'commonjs'
|
|
848
|
+
if (directory === projectRoot) break
|
|
616
849
|
directory = path.dirname(directory)
|
|
617
850
|
}
|
|
618
|
-
return
|
|
851
|
+
return 'commonjs'
|
|
619
852
|
}
|
|
620
853
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
854
|
+
/**
|
|
855
|
+
* Discovers bounded CI configuration paths without interpreting them.
|
|
856
|
+
*
|
|
857
|
+
* @param {string} root repository root
|
|
858
|
+
* @returns {object} CI discovery data
|
|
859
|
+
*/
|
|
860
|
+
function discoverCiFiles (root) {
|
|
861
|
+
const found = []
|
|
862
|
+
for (const relativePath of CI_PATHS) {
|
|
863
|
+
const absolutePath = path.join(root, relativePath)
|
|
864
|
+
try {
|
|
865
|
+
const stat = fs.lstatSync(absolutePath)
|
|
866
|
+
if (stat.isSymbolicLink()) continue
|
|
867
|
+
if (stat.isFile()) {
|
|
868
|
+
found.push(relativePath)
|
|
869
|
+
} else if (stat.isDirectory()) {
|
|
870
|
+
for (const entry of readDirectoryEntries(absolutePath).slice(0, MAX_CI_FILES)) {
|
|
871
|
+
if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) found.push(path.join(relativePath, entry.name))
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
} catch {}
|
|
875
|
+
}
|
|
876
|
+
const normalized = found.map(filename => filename.split(path.sep).join('/')).sort()
|
|
877
|
+
return {
|
|
878
|
+
found: normalized,
|
|
879
|
+
reviewRequired: normalized.length > 0,
|
|
880
|
+
reviewTargets: rankCiReviewTargets(normalized).slice(0, MAX_CI_REVIEW_TARGETS),
|
|
881
|
+
searched: CI_PATHS,
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Ranks likely test workflows first.
|
|
887
|
+
*
|
|
888
|
+
* @param {string[]} files discovered CI paths
|
|
889
|
+
* @returns {string[]} ranked paths
|
|
890
|
+
*/
|
|
891
|
+
function rankCiReviewTargets (files) {
|
|
892
|
+
return [...files].sort((left, right) => getCiRank(left) - getCiRank(right) || left.localeCompare(right))
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Returns a lower-is-better CI review rank.
|
|
897
|
+
*
|
|
898
|
+
* @param {string} filename CI path
|
|
899
|
+
* @returns {number} rank
|
|
900
|
+
*/
|
|
901
|
+
function getCiRank (filename) {
|
|
902
|
+
const value = filename.toLowerCase()
|
|
903
|
+
if (/(?:test|ci|build|unit|integration)/.test(value)) return 0
|
|
904
|
+
return 10
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Finds the package manifest that owns a detection.
|
|
909
|
+
*
|
|
910
|
+
* @param {string} repositoryRoot repository root
|
|
911
|
+
* @param {string|undefined} location detected relative path
|
|
912
|
+
* @returns {{json: object, path: string}} package metadata
|
|
913
|
+
*/
|
|
914
|
+
function getDetectionPackageJson (repositoryRoot, location) {
|
|
915
|
+
const candidate = typeof location === 'string' && path.basename(location) === 'package.json'
|
|
916
|
+
? path.resolve(repositoryRoot, location)
|
|
917
|
+
: path.join(repositoryRoot, 'package.json')
|
|
918
|
+
const json = readJson(candidate)
|
|
919
|
+
if (json) return { json, path: candidate }
|
|
920
|
+
return {
|
|
921
|
+
json: readJson(path.join(repositoryRoot, 'package.json')) || {},
|
|
922
|
+
path: path.join(repositoryRoot, 'package.json'),
|
|
923
|
+
}
|
|
624
924
|
}
|
|
625
925
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
926
|
+
/**
|
|
927
|
+
* Safely reads a bounded directory.
|
|
928
|
+
*
|
|
929
|
+
* @param {string} directory directory path
|
|
930
|
+
* @returns {fs.Dirent[]} entries
|
|
931
|
+
*/
|
|
932
|
+
function readDirectoryEntries (directory) {
|
|
633
933
|
try {
|
|
634
|
-
return fs.readdirSync(
|
|
934
|
+
return fs.readdirSync(directory, { withFileTypes: true }).slice(0, MAX_DIRECTORY_ENTRIES)
|
|
635
935
|
} catch {
|
|
636
936
|
return []
|
|
637
937
|
}
|
|
638
938
|
}
|
|
639
939
|
|
|
640
940
|
/**
|
|
641
|
-
*
|
|
941
|
+
* Reads bounded UTF-8 source.
|
|
642
942
|
*
|
|
643
|
-
* @param {string}
|
|
644
|
-
* @
|
|
645
|
-
* @returns {string} absolute package.json path
|
|
943
|
+
* @param {string} filename file path
|
|
944
|
+
* @returns {string|undefined} source
|
|
646
945
|
*/
|
|
647
|
-
function
|
|
648
|
-
|
|
649
|
-
|
|
946
|
+
function readText (filename) {
|
|
947
|
+
try {
|
|
948
|
+
const stat = fs.lstatSync(filename)
|
|
949
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES) return
|
|
950
|
+
return fs.readFileSync(filename, 'utf8')
|
|
951
|
+
} catch {}
|
|
650
952
|
}
|
|
651
953
|
|
|
652
954
|
/**
|
|
653
|
-
*
|
|
955
|
+
* Reads JSON without throwing.
|
|
654
956
|
*
|
|
655
|
-
* @param {string}
|
|
656
|
-
* @
|
|
657
|
-
* @param {object} packageJson detected project package.json
|
|
658
|
-
* @returns {string|null} installed or declared framework version
|
|
957
|
+
* @param {string} filename JSON path
|
|
958
|
+
* @returns {object|undefined} parsed object
|
|
659
959
|
*/
|
|
660
|
-
function
|
|
661
|
-
|
|
960
|
+
function readJson (filename) {
|
|
961
|
+
const source = readText(filename)
|
|
962
|
+
if (source === undefined) return
|
|
662
963
|
try {
|
|
663
|
-
|
|
664
|
-
return readJson(filename)?.version || null
|
|
964
|
+
return JSON.parse(source)
|
|
665
965
|
} catch {}
|
|
966
|
+
}
|
|
666
967
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
968
|
+
/**
|
|
969
|
+
* Returns whether a framework target was selected.
|
|
970
|
+
*
|
|
971
|
+
* @param {string} framework framework id
|
|
972
|
+
* @param {Set<string>} selected selected ids
|
|
973
|
+
* @returns {boolean} selection result
|
|
974
|
+
*/
|
|
975
|
+
function isSelected (framework, selected) {
|
|
976
|
+
return selected.size === 0 || selected.has(framework)
|
|
671
977
|
}
|
|
672
978
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
found.push(relativePath)
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
return {
|
|
688
|
-
searched: [...CI_PATHS],
|
|
689
|
-
found,
|
|
690
|
-
method: 'deterministic-known-ci-paths',
|
|
691
|
-
warnings: [],
|
|
692
|
-
notes: ['Generated by --init-manifest; select and record one replayable CI test step before live validation.'],
|
|
693
|
-
}
|
|
979
|
+
/**
|
|
980
|
+
* Returns a stable project identifier.
|
|
981
|
+
*
|
|
982
|
+
* @param {object} packageJson package metadata
|
|
983
|
+
* @param {string} projectRoot project root
|
|
984
|
+
* @param {string} repositoryRoot repository root
|
|
985
|
+
* @returns {string} project id
|
|
986
|
+
*/
|
|
987
|
+
function getProjectId (packageJson, projectRoot, repositoryRoot) {
|
|
988
|
+
const value = packageJson.name || path.relative(repositoryRoot, projectRoot) || 'root'
|
|
989
|
+
return value.replace(/^@/, '').replaceAll(/[^A-Za-z0-9_.-]+/g, '-')
|
|
694
990
|
}
|
|
695
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Detects package-manager metadata for reporting only.
|
|
994
|
+
*
|
|
995
|
+
* @param {string} root repository root
|
|
996
|
+
* @returns {string} package manager
|
|
997
|
+
*/
|
|
696
998
|
function detectPackageManager (root) {
|
|
697
999
|
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
698
1000
|
if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn'
|
|
699
|
-
return 'npm'
|
|
1001
|
+
if (fs.existsSync(path.join(root, 'package-lock.json'))) return 'npm'
|
|
1002
|
+
return 'unknown'
|
|
700
1003
|
}
|
|
701
1004
|
|
|
1005
|
+
/**
|
|
1006
|
+
* Detects workspace metadata for reporting only.
|
|
1007
|
+
*
|
|
1008
|
+
* @param {string} root repository root
|
|
1009
|
+
* @returns {string} workspace manager
|
|
1010
|
+
*/
|
|
702
1011
|
function detectWorkspaceManager (root) {
|
|
703
1012
|
if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml'))) return 'pnpm'
|
|
704
1013
|
const packageJson = readJson(path.join(root, 'package.json'))
|
|
705
1014
|
return packageJson?.workspaces ? detectPackageManager(root) : 'none'
|
|
706
1015
|
}
|
|
707
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* Returns a manifest operating-system name.
|
|
1019
|
+
*
|
|
1020
|
+
* @param {string} platform Node.js platform
|
|
1021
|
+
* @returns {string} OS name
|
|
1022
|
+
*/
|
|
708
1023
|
function getManifestOs (platform) {
|
|
709
|
-
|
|
710
|
-
if (platform === 'darwin' || platform === 'linux') return platform
|
|
711
|
-
return 'unknown'
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function findYarnRelease (root) {
|
|
715
|
-
const directory = path.join(root, '.yarn', 'releases')
|
|
716
|
-
try {
|
|
717
|
-
const release = fs.readdirSync(directory).find(filename => /^yarn-.+\.cjs$/.test(filename))
|
|
718
|
-
return release && path.join(directory, release)
|
|
719
|
-
} catch {}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function getProjectIdentifier (packageJson, projectRoot, repositoryRoot) {
|
|
723
|
-
if (packageJson.name) return packageJson.name.replaceAll(/[^A-Za-z0-9._-]+/g, '-')
|
|
724
|
-
return (path.relative(repositoryRoot, projectRoot) || 'root').replaceAll(path.sep, '-')
|
|
1024
|
+
return platform === 'win32' ? 'windows' : platform
|
|
725
1025
|
}
|
|
726
1026
|
|
|
1027
|
+
/**
|
|
1028
|
+
* Checks lexical path containment.
|
|
1029
|
+
*
|
|
1030
|
+
* @param {string} root root path
|
|
1031
|
+
* @param {string} filename candidate path
|
|
1032
|
+
* @returns {boolean} whether the candidate is contained
|
|
1033
|
+
*/
|
|
727
1034
|
function isPathInside (root, filename) {
|
|
728
|
-
const relative = path.relative(root, filename)
|
|
729
|
-
return relative
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function readJson (filename) {
|
|
733
|
-
try {
|
|
734
|
-
return JSON.parse(fs.readFileSync(filename, 'utf8'))
|
|
735
|
-
} catch {}
|
|
1035
|
+
const relative = path.relative(path.resolve(root), path.resolve(filename))
|
|
1036
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
736
1037
|
}
|
|
737
1038
|
|
|
738
1039
|
module.exports = { createManifestScaffold }
|