@autotests/playwright-impact 0.1.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/README.md +117 -0
- package/package.json +33 -0
- package/src/analyze-impacted-specs.js +294 -0
- package/src/format-analyze-result.js +29 -0
- package/src/index.d.ts +74 -0
- package/src/index.js +10 -0
- package/src/modules/class-impact-helpers.js +86 -0
- package/src/modules/file-and-git-helpers.js +234 -0
- package/src/modules/fixture-map-helpers.js +167 -0
- package/src/modules/global-watch-helpers.js +278 -0
- package/src/modules/import-impact-helpers.js +236 -0
- package/src/modules/method-filter-helpers.js +338 -0
- package/src/modules/method-impact-helpers.js +757 -0
- package/src/modules/shell.js +24 -0
- package/src/modules/spec-selection-helpers.js +73 -0
- package/tests/_test-helpers.js +45 -0
- package/tests/analyze-impacted-specs.integration.test.js +477 -0
- package/tests/analyze-impacted-specs.test.js +36 -0
- package/tests/class-impact-helpers.test.js +101 -0
- package/tests/file-and-git-helpers.test.js +140 -0
- package/tests/file-status-compat.test.js +55 -0
- package/tests/fixture-map-helpers.test.js +118 -0
- package/tests/format-analyze-result.test.js +26 -0
- package/tests/global-watch-helpers.test.js +92 -0
- package/tests/method-filter-helpers.test.js +316 -0
- package/tests/method-impact-helpers.test.js +195 -0
- package/tests/semantic-coverage-matrix.test.js +381 -0
- package/tests/spec-selection-helpers.test.js +115 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { runCommand } = require('./shell');
|
|
6
|
+
|
|
7
|
+
const STATUS_PRIORITY = { D: 4, R: 3, M: 2, A: 1 };
|
|
8
|
+
const SUPPORTED_FILE_EXTENSIONS = new Set(['.ts', '.tsx']);
|
|
9
|
+
|
|
10
|
+
const readFileFromBaseRef = ({ repoRoot, relativePath, baseRef }) => {
|
|
11
|
+
if (!relativePath) return null;
|
|
12
|
+
const effectiveBaseRef = String(baseRef || 'HEAD').trim();
|
|
13
|
+
if (!effectiveBaseRef) return null;
|
|
14
|
+
const showResult = runCommand('git', ['show', `${effectiveBaseRef}:${relativePath}`], { cwd: repoRoot });
|
|
15
|
+
if (showResult.status !== 0 || showResult.error) return null;
|
|
16
|
+
return typeof showResult.stdout === 'string' ? showResult.stdout : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const readFileFromWorkingTree = ({ repoRoot, relativePath }) => {
|
|
20
|
+
if (!relativePath) return null;
|
|
21
|
+
const absolutePath = path.join(repoRoot, relativePath);
|
|
22
|
+
if (!fs.existsSync(absolutePath)) return null;
|
|
23
|
+
return fs.readFileSync(absolutePath, 'utf8');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const parseChangedEntryLine = (line) => {
|
|
27
|
+
const parts = line.split('\t').filter(Boolean);
|
|
28
|
+
if (parts.length < 2) return null;
|
|
29
|
+
|
|
30
|
+
const rawStatus = String(parts[0] || '').trim().toUpperCase();
|
|
31
|
+
if (!rawStatus) return null;
|
|
32
|
+
|
|
33
|
+
if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) {
|
|
34
|
+
const oldPath = String(parts[1] || '').trim();
|
|
35
|
+
const newPath = String(parts[2] || '').trim();
|
|
36
|
+
if (!oldPath || !newPath) return null;
|
|
37
|
+
return {
|
|
38
|
+
status: rawStatus.startsWith('R') ? 'R' : 'C',
|
|
39
|
+
oldPath,
|
|
40
|
+
newPath,
|
|
41
|
+
effectivePath: newPath,
|
|
42
|
+
rawStatus,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const shortStatus = rawStatus[0];
|
|
47
|
+
const targetPath = String(parts[1] || '').trim();
|
|
48
|
+
if (!targetPath) return null;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
status: shortStatus,
|
|
52
|
+
oldPath: shortStatus === 'A' ? null : targetPath,
|
|
53
|
+
newPath: shortStatus === 'D' ? null : targetPath,
|
|
54
|
+
effectivePath: targetPath,
|
|
55
|
+
rawStatus,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const listFilesRecursive = (rootDir) => {
|
|
60
|
+
if (!fs.existsSync(rootDir)) return [];
|
|
61
|
+
const files = [];
|
|
62
|
+
const stack = [rootDir];
|
|
63
|
+
|
|
64
|
+
while (stack.length > 0) {
|
|
65
|
+
const current = stack.pop();
|
|
66
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const nextPath = path.join(current, entry.name);
|
|
69
|
+
if (entry.isDirectory()) stack.push(nextPath);
|
|
70
|
+
else files.push(nextPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return files;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const runDiffAndParse = ({ repoRoot, args }) => {
|
|
78
|
+
// Use rename detection to keep semantic compare stable across pure file moves/renames.
|
|
79
|
+
const result = runCommand('git', ['diff', '--name-status', '-M', ...args], { cwd: repoRoot });
|
|
80
|
+
if (result.error) throw new Error(`Failed to execute git diff: ${result.error.message}`);
|
|
81
|
+
if (result.status !== 0) {
|
|
82
|
+
const details = (result.stderr || result.stdout || '').trim();
|
|
83
|
+
throw new Error(`git diff failed with code ${result.status}: ${details}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result.stdout
|
|
87
|
+
.split(/\r?\n/)
|
|
88
|
+
.map((line) => line.trim())
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map(parseChangedEntryLine)
|
|
91
|
+
.filter((entry) => Boolean(entry));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const normalizeEntryStatus = (entry, warnings) => {
|
|
95
|
+
// Compatibility fallback:
|
|
96
|
+
// - C behaves like an add because there is no stable base path identity
|
|
97
|
+
// - T/U/unknown behave like modify to preserve fail-open behavior
|
|
98
|
+
const status = String(entry.status || '').toUpperCase();
|
|
99
|
+
if (['A', 'M', 'D', 'R'].includes(status)) return { ...entry, status };
|
|
100
|
+
|
|
101
|
+
if (status === 'C') {
|
|
102
|
+
warnings.push(`Status fallback: ${entry.rawStatus || 'C'} mapped to A for ${entry.effectivePath}`);
|
|
103
|
+
return { ...entry, status: 'A', oldPath: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (status === 'T' || status === 'U') {
|
|
107
|
+
warnings.push(`Status fallback: ${entry.rawStatus || status} mapped to M for ${entry.effectivePath}`);
|
|
108
|
+
return { ...entry, status: 'M', oldPath: entry.oldPath || entry.effectivePath, newPath: entry.newPath || entry.effectivePath };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
warnings.push(`Status fallback: ${entry.rawStatus || status || 'unknown'} mapped to M for ${entry.effectivePath}`);
|
|
112
|
+
return { ...entry, status: 'M', oldPath: entry.oldPath || entry.effectivePath, newPath: entry.newPath || entry.effectivePath };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const mergeByPriority = (existing, incoming) => {
|
|
116
|
+
if (!existing) return incoming;
|
|
117
|
+
const currentPriority = STATUS_PRIORITY[existing.status] || 0;
|
|
118
|
+
const incomingPriority = STATUS_PRIORITY[incoming.status] || 0;
|
|
119
|
+
if (incomingPriority > currentPriority) return incoming;
|
|
120
|
+
if (incomingPriority < currentPriority) return existing;
|
|
121
|
+
|
|
122
|
+
// If priorities are equal, keep entry with richer rename info.
|
|
123
|
+
if (incoming.status === 'R' && incoming.oldPath && incoming.newPath) return incoming;
|
|
124
|
+
return existing;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const isValidExtension = (filePath, fileExtensions) => {
|
|
128
|
+
const ext = path.extname(filePath);
|
|
129
|
+
return fileExtensions.includes(ext);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const getUntrackedPaths = ({ repoRoot }) => {
|
|
133
|
+
const result = runCommand('git', ['ls-files', '--others', '--exclude-standard'], { cwd: repoRoot });
|
|
134
|
+
if (result.error || result.status !== 0) return [];
|
|
135
|
+
|
|
136
|
+
return result.stdout
|
|
137
|
+
.split(/\r?\n/)
|
|
138
|
+
.map((line) => line.trim())
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.sort((a, b) => a.localeCompare(b));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const getUntrackedSpecPaths = ({ repoRoot, changedSpecPrefix, fileExtensions = ['.ts', '.tsx'] }) => {
|
|
144
|
+
return getUntrackedPaths({ repoRoot })
|
|
145
|
+
.filter((filePath) => filePath.startsWith(changedSpecPrefix))
|
|
146
|
+
.filter((filePath) => fileExtensions.some((ext) => filePath.endsWith(`.spec${ext}`)));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const getUntrackedSourceEntries = ({ repoRoot, profile, fileExtensions = ['.ts', '.tsx'] }) => {
|
|
150
|
+
return getUntrackedPaths({ repoRoot })
|
|
151
|
+
.filter((filePath) => isValidExtension(filePath, fileExtensions))
|
|
152
|
+
.filter((filePath) => profile.isRelevantPomPath(filePath))
|
|
153
|
+
.map((filePath) => ({
|
|
154
|
+
status: 'A',
|
|
155
|
+
oldPath: null,
|
|
156
|
+
newPath: filePath,
|
|
157
|
+
effectivePath: filePath,
|
|
158
|
+
rawStatus: 'A (untracked source)',
|
|
159
|
+
}));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build normalized changed entries for analysis.
|
|
164
|
+
* Sources:
|
|
165
|
+
* - git diff <base>...HEAD (if baseRef is set)
|
|
166
|
+
* - git diff HEAD (working tree, optionally merged with base mode)
|
|
167
|
+
* - untracked source files matching profile.isRelevantPomPath
|
|
168
|
+
*/
|
|
169
|
+
const getChangedEntries = ({ repoRoot, baseRef, includeWorkingTreeWithBase = true, profile = null, fileExtensions = ['.ts', '.tsx'] }) => {
|
|
170
|
+
const warnings = [];
|
|
171
|
+
let statusFallbackHits = 0;
|
|
172
|
+
|
|
173
|
+
const baseHeadEntries = baseRef ? runDiffAndParse({ repoRoot, args: [`${baseRef}...HEAD`] }) : [];
|
|
174
|
+
const workingTreeEntries = (!baseRef || includeWorkingTreeWithBase) ? runDiffAndParse({ repoRoot, args: ['HEAD'] }) : [];
|
|
175
|
+
const combined = [...baseHeadEntries, ...workingTreeEntries];
|
|
176
|
+
|
|
177
|
+
if (!baseRef && combined.length === 0) {
|
|
178
|
+
// Keep backward-compatible behavior for local-only mode where `HEAD` diff is the primary source.
|
|
179
|
+
combined.push(...workingTreeEntries);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (profile && typeof profile.isRelevantPomPath === 'function') {
|
|
183
|
+
combined.push(...getUntrackedSourceEntries({ repoRoot, profile, fileExtensions }));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Deterministic status merge by effective path with explicit precedence (D > R > M > A).
|
|
187
|
+
const mergedByPath = new Map();
|
|
188
|
+
for (const entry of combined) {
|
|
189
|
+
const normalized = normalizeEntryStatus(entry, warnings);
|
|
190
|
+
if (!['A', 'M', 'D', 'R'].includes(normalized.status)) continue;
|
|
191
|
+
if (normalized.status !== entry.status) statusFallbackHits += 1;
|
|
192
|
+
|
|
193
|
+
const key = normalized.effectivePath;
|
|
194
|
+
const existing = mergedByPath.get(key);
|
|
195
|
+
mergedByPath.set(key, mergeByPriority(existing, normalized));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const entries = Array.from(mergedByPath.values()).sort((a, b) => a.effectivePath.localeCompare(b.effectivePath));
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
entries,
|
|
202
|
+
warnings,
|
|
203
|
+
statusFallbackHits,
|
|
204
|
+
changedEntriesBySource: {
|
|
205
|
+
fromBaseHead: baseHeadEntries.length,
|
|
206
|
+
fromWorkingTree: workingTreeEntries.length,
|
|
207
|
+
fromUntracked: profile ? getUntrackedSourceEntries({ repoRoot, profile, fileExtensions }).length : 0,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const readChangeContents = ({ repoRoot, entry, baseRef }) => {
|
|
213
|
+
const basePath = entry?.status === 'R' ? entry.oldPath : entry?.oldPath;
|
|
214
|
+
const headPath = entry?.status === 'R' ? entry.newPath : entry?.newPath;
|
|
215
|
+
|
|
216
|
+
const baseContent = basePath ? readFileFromBaseRef({ repoRoot, relativePath: basePath, baseRef }) : null;
|
|
217
|
+
const headContent = headPath ? readFileFromWorkingTree({ repoRoot, relativePath: headPath }) : null;
|
|
218
|
+
|
|
219
|
+
return { basePath: basePath || null, headPath: headPath || null, baseContent, headContent };
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
SUPPORTED_FILE_EXTENSIONS,
|
|
224
|
+
listFilesRecursive,
|
|
225
|
+
getChangedEntries,
|
|
226
|
+
readChangeContents,
|
|
227
|
+
getUntrackedSpecPaths,
|
|
228
|
+
getUntrackedSourceEntries,
|
|
229
|
+
__testOnly: {
|
|
230
|
+
parseChangedEntryLine,
|
|
231
|
+
normalizeEntryStatus,
|
|
232
|
+
mergeByPriority,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const ts = require('typescript');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse fixture declarations from fixture types file.
|
|
8
|
+
* Supported shapes include direct types, namespace-qualified types,
|
|
9
|
+
* interfaces, and type intersections.
|
|
10
|
+
*/
|
|
11
|
+
const parseFixtureClassMap = ({ typesPath }) => {
|
|
12
|
+
if (!typesPath || !fs.existsSync(typesPath)) return new Map();
|
|
13
|
+
const content = fs.readFileSync(typesPath, 'utf8');
|
|
14
|
+
const classToFixtureKeys = new Map();
|
|
15
|
+
const sourceFile = ts.createSourceFile(typesPath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
16
|
+
const declarationsByName = new Map();
|
|
17
|
+
const memoByDeclarationName = new Map();
|
|
18
|
+
|
|
19
|
+
const addMapping = (fixtureKey, className) => {
|
|
20
|
+
if (!fixtureKey || !className) return;
|
|
21
|
+
if (!classToFixtureKeys.has(className)) classToFixtureKeys.set(className, new Set());
|
|
22
|
+
classToFixtureKeys.get(className).add(fixtureKey);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const getDeclarationName = (node) => {
|
|
26
|
+
if (!node || !node.name || !ts.isIdentifier(node.name)) return null;
|
|
27
|
+
return node.name.text;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getEntityRightmostName = (entity) => {
|
|
31
|
+
if (!entity) return null;
|
|
32
|
+
if (ts.isIdentifier(entity)) return entity.text;
|
|
33
|
+
if (ts.isQualifiedName(entity)) return entity.right.text;
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const getExpressionRightmostName = (expression) => {
|
|
38
|
+
if (!expression) return null;
|
|
39
|
+
if (ts.isIdentifier(expression)) return expression.text;
|
|
40
|
+
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
|
|
41
|
+
return null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getPropertyNameText = (nameNode) => {
|
|
45
|
+
if (!nameNode) return null;
|
|
46
|
+
if (ts.isIdentifier(nameNode)) return nameNode.text;
|
|
47
|
+
if (ts.isStringLiteral(nameNode) || ts.isNoSubstitutionTemplateLiteral(nameNode)) return nameNode.text;
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getTypeReferenceName = (typeNode) => {
|
|
52
|
+
if (!typeNode || !ts.isTypeReferenceNode(typeNode)) return null;
|
|
53
|
+
return getEntityRightmostName(typeNode.typeName);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const isClassLikeTypeName = (typeName) => Boolean(typeName) && /^[A-Z]/.test(typeName);
|
|
57
|
+
|
|
58
|
+
const collectPairsFromTypeNode = (typeNode, visitingNames) => {
|
|
59
|
+
if (!typeNode) return [];
|
|
60
|
+
|
|
61
|
+
if (ts.isTypeLiteralNode(typeNode)) {
|
|
62
|
+
const pairs = [];
|
|
63
|
+
for (const member of typeNode.members) {
|
|
64
|
+
if (!ts.isPropertySignature(member) || !member.type) continue;
|
|
65
|
+
const fixtureKey = getPropertyNameText(member.name);
|
|
66
|
+
const className = getTypeReferenceName(member.type);
|
|
67
|
+
if (fixtureKey && isClassLikeTypeName(className)) {
|
|
68
|
+
pairs.push([fixtureKey, className]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return pairs;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (ts.isIntersectionTypeNode(typeNode) || ts.isUnionTypeNode(typeNode)) {
|
|
75
|
+
return typeNode.types.flatMap((child) => collectPairsFromTypeNode(child, visitingNames));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (ts.isParenthesizedTypeNode(typeNode)) {
|
|
79
|
+
return collectPairsFromTypeNode(typeNode.type, visitingNames);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (ts.isTypeReferenceNode(typeNode)) {
|
|
83
|
+
const refName = getEntityRightmostName(typeNode.typeName);
|
|
84
|
+
if (!refName) return [];
|
|
85
|
+
return collectPairsFromDeclarationName(refName, visitingNames);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return [];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const collectPairsFromInterface = (declaration, visitingNames) => {
|
|
92
|
+
const pairs = [];
|
|
93
|
+
for (const member of declaration.members) {
|
|
94
|
+
if (!ts.isPropertySignature(member) || !member.type) continue;
|
|
95
|
+
const fixtureKey = getPropertyNameText(member.name);
|
|
96
|
+
const className = getTypeReferenceName(member.type);
|
|
97
|
+
if (fixtureKey && isClassLikeTypeName(className)) pairs.push([fixtureKey, className]);
|
|
98
|
+
if (member.type) pairs.push(...collectPairsFromTypeNode(member.type, visitingNames));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const heritageClause of declaration.heritageClauses || []) {
|
|
102
|
+
if (heritageClause.token !== ts.SyntaxKind.ExtendsKeyword) continue;
|
|
103
|
+
for (const heritageType of heritageClause.types) {
|
|
104
|
+
const baseName = getExpressionRightmostName(heritageType.expression);
|
|
105
|
+
if (!baseName) continue;
|
|
106
|
+
pairs.push(...collectPairsFromDeclarationName(baseName, visitingNames));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return pairs;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const collectPairsFromDeclarationName = (declarationName, visitingNames) => {
|
|
114
|
+
if (!declarationName) return [];
|
|
115
|
+
if (memoByDeclarationName.has(declarationName)) return memoByDeclarationName.get(declarationName);
|
|
116
|
+
if (visitingNames.has(declarationName)) return [];
|
|
117
|
+
|
|
118
|
+
visitingNames.add(declarationName);
|
|
119
|
+
const declaration = declarationsByName.get(declarationName);
|
|
120
|
+
if (!declaration) {
|
|
121
|
+
visitingNames.delete(declarationName);
|
|
122
|
+
memoByDeclarationName.set(declarationName, []);
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let pairs = [];
|
|
127
|
+
if (ts.isTypeAliasDeclaration(declaration)) {
|
|
128
|
+
pairs = collectPairsFromTypeNode(declaration.type, visitingNames);
|
|
129
|
+
} else if (ts.isInterfaceDeclaration(declaration)) {
|
|
130
|
+
pairs = collectPairsFromInterface(declaration, visitingNames);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
visitingNames.delete(declarationName);
|
|
134
|
+
memoByDeclarationName.set(declarationName, pairs);
|
|
135
|
+
return pairs;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
for (const statement of sourceFile.statements) {
|
|
139
|
+
if (!ts.isTypeAliasDeclaration(statement) && !ts.isInterfaceDeclaration(statement)) continue;
|
|
140
|
+
const declarationName = getDeclarationName(statement);
|
|
141
|
+
if (!declarationName) continue;
|
|
142
|
+
declarationsByName.set(declarationName, statement);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const declarationName of declarationsByName.keys()) {
|
|
146
|
+
const pairs = collectPairsFromDeclarationName(declarationName, new Set());
|
|
147
|
+
for (const [fixtureKey, className] of pairs) addMapping(fixtureKey, className);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return classToFixtureKeys;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Return bidirectional fixture mappings used by Stage A and Stage B.
|
|
155
|
+
*/
|
|
156
|
+
const parseFixtureMappings = ({ typesPath }) => {
|
|
157
|
+
const classToFixtureKeys = parseFixtureClassMap({ typesPath });
|
|
158
|
+
const fixtureKeyToClass = new Map();
|
|
159
|
+
for (const [className, fixtureKeys] of classToFixtureKeys.entries()) {
|
|
160
|
+
for (const fixtureKey of fixtureKeys) fixtureKeyToClass.set(fixtureKey, className);
|
|
161
|
+
}
|
|
162
|
+
return { classToFixtureKeys, fixtureKeyToClass };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
parseFixtureMappings,
|
|
167
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const ts = require('typescript');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_GLOBAL_WATCH_PATTERNS = [
|
|
8
|
+
'playwright.stem.config.ts',
|
|
9
|
+
'playwright.mn.config.ts',
|
|
10
|
+
'playwright.e2e.config.ts',
|
|
11
|
+
'src/global-setup.ts',
|
|
12
|
+
'src/global-setup-stem.ts',
|
|
13
|
+
'src/global-setup-mn.ts',
|
|
14
|
+
'src/fixtures/**',
|
|
15
|
+
'src/setup/**',
|
|
16
|
+
'src/config/config.ts',
|
|
17
|
+
'src/config/urls.ts',
|
|
18
|
+
'src/reporters/**',
|
|
19
|
+
'src/scripts/verify-*.js',
|
|
20
|
+
'src/api/mocks/**',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const WATCH_DEP_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.yml', '.yaml'];
|
|
24
|
+
|
|
25
|
+
const normalizePath = (filePath) => String(filePath || '').replace(/\\/g, '/');
|
|
26
|
+
|
|
27
|
+
const globToRegex = (globPattern) => {
|
|
28
|
+
const normalized = normalizePath(globPattern).replace(/^\.\//, '');
|
|
29
|
+
let regex = '^';
|
|
30
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
31
|
+
const char = normalized[index];
|
|
32
|
+
const next = normalized[index + 1];
|
|
33
|
+
if (char === '*' && next === '*') {
|
|
34
|
+
regex += '.*';
|
|
35
|
+
index += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (char === '*') {
|
|
39
|
+
regex += '[^/]*';
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if ('\\^$+?.()|{}[]'.includes(char)) {
|
|
43
|
+
regex += `\\${char}`;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
regex += char;
|
|
47
|
+
}
|
|
48
|
+
regex += '$';
|
|
49
|
+
return new RegExp(regex);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const getDefaultGlobalWatchPatterns = () => [...DEFAULT_GLOBAL_WATCH_PATTERNS];
|
|
53
|
+
|
|
54
|
+
const getCandidateFilePaths = (basePath) => {
|
|
55
|
+
const ext = path.extname(basePath).toLowerCase();
|
|
56
|
+
if (WATCH_DEP_EXTENSIONS.includes(ext)) return [basePath];
|
|
57
|
+
|
|
58
|
+
const candidates = [];
|
|
59
|
+
candidates.push(...WATCH_DEP_EXTENSIONS.map((fileExt) => `${basePath}${fileExt}`));
|
|
60
|
+
candidates.push(...WATCH_DEP_EXTENSIONS.map((fileExt) => path.join(basePath, `index${fileExt}`)));
|
|
61
|
+
return candidates;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const readTsConfigPathAliases = (repoRoot) => {
|
|
65
|
+
const tsConfigPath = path.join(repoRoot, 'tsconfig.json');
|
|
66
|
+
if (!fs.existsSync(tsConfigPath)) return [];
|
|
67
|
+
|
|
68
|
+
const rawText = fs.readFileSync(tsConfigPath, 'utf8');
|
|
69
|
+
const parsedResult = ts.parseConfigFileTextToJson(tsConfigPath, rawText);
|
|
70
|
+
const parsed = parsedResult?.config;
|
|
71
|
+
if (!parsed || typeof parsed !== 'object') return [];
|
|
72
|
+
|
|
73
|
+
const baseUrl = parsed?.compilerOptions?.baseUrl || '.';
|
|
74
|
+
const paths = parsed?.compilerOptions?.paths || {};
|
|
75
|
+
const entries = [];
|
|
76
|
+
|
|
77
|
+
for (const [aliasPattern, targets] of Object.entries(paths)) {
|
|
78
|
+
if (!Array.isArray(targets) || targets.length === 0) continue;
|
|
79
|
+
const hasWildcard = aliasPattern.includes('*');
|
|
80
|
+
const [aliasPrefix, aliasSuffix] = hasWildcard ? aliasPattern.split('*') : [aliasPattern, ''];
|
|
81
|
+
|
|
82
|
+
entries.push({
|
|
83
|
+
aliasPattern,
|
|
84
|
+
aliasPrefix,
|
|
85
|
+
aliasSuffix,
|
|
86
|
+
hasWildcard,
|
|
87
|
+
targets: targets.map((targetPattern) => {
|
|
88
|
+
const [targetPrefix, targetSuffix] = targetPattern.includes('*') ? targetPattern.split('*') : [targetPattern, ''];
|
|
89
|
+
return { targetPrefix, targetSuffix };
|
|
90
|
+
}),
|
|
91
|
+
baseUrlAbs: path.resolve(repoRoot, baseUrl),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return entries;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const resolveModuleSpecifier = ({ importerAbsPath, moduleSpecifier, repoRoot, aliasEntries }) => {
|
|
99
|
+
const resolved = [];
|
|
100
|
+
|
|
101
|
+
const addResolvedFromBase = (basePathAbs) => {
|
|
102
|
+
for (const candidate of getCandidateFilePaths(basePathAbs)) {
|
|
103
|
+
if (!fs.existsSync(candidate)) continue;
|
|
104
|
+
resolved.push(path.resolve(candidate));
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (moduleSpecifier.startsWith('.')) {
|
|
109
|
+
addResolvedFromBase(path.resolve(path.dirname(importerAbsPath), moduleSpecifier));
|
|
110
|
+
return Array.from(new Set(resolved));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const looksLikeFileName = /\.[A-Za-z0-9]+$/.test(moduleSpecifier);
|
|
114
|
+
if (looksLikeFileName) {
|
|
115
|
+
let currentDir = path.dirname(importerAbsPath);
|
|
116
|
+
const repoRootAbs = path.resolve(repoRoot);
|
|
117
|
+
while (currentDir.startsWith(repoRootAbs)) {
|
|
118
|
+
const candidate = path.resolve(currentDir, moduleSpecifier);
|
|
119
|
+
if (fs.existsSync(candidate)) resolved.push(candidate);
|
|
120
|
+
if (currentDir === repoRootAbs) break;
|
|
121
|
+
currentDir = path.dirname(currentDir);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const alias of aliasEntries) {
|
|
126
|
+
if (!alias.hasWildcard) {
|
|
127
|
+
if (moduleSpecifier !== alias.aliasPattern) continue;
|
|
128
|
+
for (const target of alias.targets) addResolvedFromBase(path.resolve(alias.baseUrlAbs, target.targetPrefix));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!moduleSpecifier.startsWith(alias.aliasPrefix) || !moduleSpecifier.endsWith(alias.aliasSuffix)) continue;
|
|
133
|
+
const wildcardValue = moduleSpecifier.slice(alias.aliasPrefix.length, moduleSpecifier.length - alias.aliasSuffix.length);
|
|
134
|
+
for (const target of alias.targets) {
|
|
135
|
+
const joined = `${target.targetPrefix}${wildcardValue}${target.targetSuffix}`;
|
|
136
|
+
addResolvedFromBase(path.resolve(alias.baseUrlAbs, joined));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return Array.from(new Set(resolved));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const extractImportSpecifiers = (sourceFile) => {
|
|
144
|
+
const specifiers = [];
|
|
145
|
+
const addText = (node) => {
|
|
146
|
+
if (node && ts.isStringLiteral(node)) specifiers.push(node.text);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const visit = (node) => {
|
|
150
|
+
if (ts.isImportDeclaration(node)) addText(node.moduleSpecifier);
|
|
151
|
+
if (ts.isExportDeclaration(node) && node.moduleSpecifier) addText(node.moduleSpecifier);
|
|
152
|
+
if (ts.isCallExpression(node)) {
|
|
153
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === 'require') addText(node.arguments?.[0]);
|
|
154
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) addText(node.arguments?.[0]);
|
|
155
|
+
for (const argument of node.arguments || []) {
|
|
156
|
+
if (ts.isStringLiteral(argument) && /\.[A-Za-z0-9]+$/.test(argument.text)) specifiers.push(argument.text);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
ts.forEachChild(node, visit);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
visit(sourceFile);
|
|
163
|
+
return specifiers;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const resolveWatchSeedFiles = ({ repoRoot, patterns, listFilesRecursive }) => {
|
|
167
|
+
const seedFiles = new Set();
|
|
168
|
+
for (const pattern of patterns) {
|
|
169
|
+
const normalizedPattern = normalizePath(pattern).replace(/^\.\//, '');
|
|
170
|
+
const hasWildcard = normalizedPattern.includes('*');
|
|
171
|
+
const absolute = path.resolve(repoRoot, normalizedPattern);
|
|
172
|
+
if (!hasWildcard) {
|
|
173
|
+
if (fs.existsSync(absolute) && fs.statSync(absolute).isFile()) seedFiles.add(absolute);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const regex = globToRegex(normalizedPattern);
|
|
178
|
+
const firstWildcardIndex = normalizedPattern.indexOf('*');
|
|
179
|
+
const fixedPrefix = firstWildcardIndex >= 0 ? normalizedPattern.slice(0, firstWildcardIndex) : normalizedPattern;
|
|
180
|
+
const prefixDir = fixedPrefix.includes('/') ? fixedPrefix.slice(0, fixedPrefix.lastIndexOf('/')) : '';
|
|
181
|
+
const scanRoot = path.resolve(repoRoot, prefixDir || '.');
|
|
182
|
+
if (!fs.existsSync(scanRoot)) continue;
|
|
183
|
+
|
|
184
|
+
const candidates = fs.statSync(scanRoot).isFile() ? [scanRoot] : listFilesRecursive(scanRoot);
|
|
185
|
+
for (const candidateAbs of candidates) {
|
|
186
|
+
if (!fs.existsSync(candidateAbs) || !fs.statSync(candidateAbs).isFile()) continue;
|
|
187
|
+
const relative = normalizePath(path.relative(repoRoot, candidateAbs));
|
|
188
|
+
if (regex.test(relative)) seedFiles.add(path.resolve(candidateAbs));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return seedFiles;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const resolveGlobalWatchClosure = ({ repoRoot, patterns, listFilesRecursive }) => {
|
|
195
|
+
const aliasEntries = readTsConfigPathAliases(repoRoot);
|
|
196
|
+
const seeds = resolveWatchSeedFiles({ repoRoot, patterns, listFilesRecursive });
|
|
197
|
+
const resolvedFiles = new Set(seeds);
|
|
198
|
+
const queue = Array.from(seeds);
|
|
199
|
+
const visited = new Set();
|
|
200
|
+
|
|
201
|
+
while (queue.length > 0) {
|
|
202
|
+
const currentAbs = path.resolve(queue.shift());
|
|
203
|
+
if (visited.has(currentAbs)) continue;
|
|
204
|
+
visited.add(currentAbs);
|
|
205
|
+
if (!fs.existsSync(currentAbs)) continue;
|
|
206
|
+
|
|
207
|
+
const ext = path.extname(currentAbs).toLowerCase();
|
|
208
|
+
if (!WATCH_DEP_EXTENSIONS.includes(ext) || ext === '.json' || ext === '.yml' || ext === '.yaml') continue;
|
|
209
|
+
|
|
210
|
+
let content;
|
|
211
|
+
try {
|
|
212
|
+
content = fs.readFileSync(currentAbs, 'utf8');
|
|
213
|
+
} catch (_error) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const scriptKind = currentAbs.endsWith('.tsx')
|
|
218
|
+
? ts.ScriptKind.TSX
|
|
219
|
+
: (currentAbs.endsWith('.js') || currentAbs.endsWith('.mjs') || currentAbs.endsWith('.cjs'))
|
|
220
|
+
? ts.ScriptKind.JS
|
|
221
|
+
: ts.ScriptKind.TS;
|
|
222
|
+
|
|
223
|
+
const sourceFile = ts.createSourceFile(currentAbs, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
224
|
+
const importSpecifiers = extractImportSpecifiers(sourceFile);
|
|
225
|
+
for (const moduleSpecifier of importSpecifiers) {
|
|
226
|
+
const dependencies = resolveModuleSpecifier({ importerAbsPath: currentAbs, moduleSpecifier, repoRoot, aliasEntries });
|
|
227
|
+
for (const dependencyAbs of dependencies) {
|
|
228
|
+
if (!dependencyAbs.startsWith(path.resolve(repoRoot))) continue;
|
|
229
|
+
if (!resolvedFiles.has(dependencyAbs)) {
|
|
230
|
+
resolvedFiles.add(dependencyAbs);
|
|
231
|
+
queue.push(dependencyAbs);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
resolvedFilesAbs: resolvedFiles,
|
|
239
|
+
resolvedFilesRelative: Array.from(resolvedFiles).map((filePath) => normalizePath(path.relative(repoRoot, filePath))).sort((a, b) => a.localeCompare(b)),
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const evaluateGlobalWatch = ({
|
|
244
|
+
repoRoot,
|
|
245
|
+
changedEntries,
|
|
246
|
+
patterns,
|
|
247
|
+
listFilesRecursive,
|
|
248
|
+
}) => {
|
|
249
|
+
const effectivePatterns = Array.isArray(patterns) && patterns.length > 0 ? patterns : getDefaultGlobalWatchPatterns();
|
|
250
|
+
const patternRegexes = effectivePatterns.map((pattern) => globToRegex(normalizePath(pattern).replace(/^\.\//, '')));
|
|
251
|
+
const closure = resolveGlobalWatchClosure({ repoRoot, patterns: effectivePatterns, listFilesRecursive });
|
|
252
|
+
const matched = new Set();
|
|
253
|
+
|
|
254
|
+
for (const entry of changedEntries) {
|
|
255
|
+
for (const candidate of [entry.effectivePath, entry.oldPath, entry.newPath]) {
|
|
256
|
+
if (!candidate) continue;
|
|
257
|
+
const relative = normalizePath(candidate).replace(/^\.\//, '');
|
|
258
|
+
const absolute = path.resolve(repoRoot, relative);
|
|
259
|
+
const byPattern = patternRegexes.some((regex) => regex.test(relative));
|
|
260
|
+
const byClosure = closure.resolvedFilesAbs.has(absolute);
|
|
261
|
+
if (byPattern || byClosure) matched.add(relative);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
matchedPaths: Array.from(matched).sort((a, b) => a.localeCompare(b)),
|
|
267
|
+
resolvedFiles: closure.resolvedFilesRelative,
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
getDefaultGlobalWatchPatterns,
|
|
273
|
+
evaluateGlobalWatch,
|
|
274
|
+
__testOnly: {
|
|
275
|
+
globToRegex,
|
|
276
|
+
resolveGlobalWatchClosure,
|
|
277
|
+
},
|
|
278
|
+
};
|