@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,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Small spawnSync wrapper with normalized process result shape.
|
|
7
|
+
*/
|
|
8
|
+
const runCommand = (command, args, options = {}) => {
|
|
9
|
+
const result = spawnSync(command, args, {
|
|
10
|
+
encoding: 'utf-8',
|
|
11
|
+
...options,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
status: result.status ?? 1,
|
|
16
|
+
stdout: result.stdout || '',
|
|
17
|
+
stderr: result.stderr || '',
|
|
18
|
+
error: result.error || null,
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
runCommand,
|
|
24
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const ts = require('typescript');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_EXTENSIONS = ['.ts', '.tsx'];
|
|
7
|
+
|
|
8
|
+
const getFixtureKeyFromBindingElement = (element) => {
|
|
9
|
+
if (!ts.isBindingElement(element)) return null;
|
|
10
|
+
|
|
11
|
+
if (element.propertyName) {
|
|
12
|
+
if (ts.isIdentifier(element.propertyName)) return element.propertyName.text;
|
|
13
|
+
if (ts.isStringLiteral(element.propertyName) || ts.isNoSubstitutionTemplateLiteral(element.propertyName)) {
|
|
14
|
+
return element.propertyName.text;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (ts.isIdentifier(element.name)) return element.name.text;
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stage A fixture extraction from spec callback parameters.
|
|
24
|
+
* Supports destructuring, aliasing, defaults, and TS syntax.
|
|
25
|
+
*/
|
|
26
|
+
const extractFixtureUsagesFromSpec = (content, filePath) => {
|
|
27
|
+
const used = new Set();
|
|
28
|
+
const scriptKind = filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
29
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
30
|
+
|
|
31
|
+
const collectFromBindingPattern = (pattern) => {
|
|
32
|
+
for (const element of pattern.elements) {
|
|
33
|
+
const fixtureKey = getFixtureKeyFromBindingElement(element);
|
|
34
|
+
if (fixtureKey) used.add(fixtureKey);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const visit = (node) => {
|
|
39
|
+
if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) && node.parameters?.length > 0) {
|
|
40
|
+
for (const parameter of node.parameters) {
|
|
41
|
+
if (ts.isObjectBindingPattern(parameter.name)) collectFromBindingPattern(parameter.name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
ts.forEachChild(node, visit);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
visit(sourceFile);
|
|
48
|
+
return used;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stage A spec prefilter:
|
|
53
|
+
* select only spec files that bind at least one impacted fixture key.
|
|
54
|
+
*/
|
|
55
|
+
const selectSpecFiles = ({ testsRootAbs, fixtureKeys, listFilesRecursive, fileExtensions = DEFAULT_EXTENSIONS }) => {
|
|
56
|
+
const specFiles = listFilesRecursive(testsRootAbs)
|
|
57
|
+
.filter((filePath) => fileExtensions.some((ext) => filePath.endsWith(`.spec${ext}`)));
|
|
58
|
+
const selected = [];
|
|
59
|
+
|
|
60
|
+
for (const filePath of specFiles) {
|
|
61
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
62
|
+
const usedKeys = extractFixtureUsagesFromSpec(content, filePath);
|
|
63
|
+
const isImpacted = Array.from(fixtureKeys).some((key) => usedKeys.has(key));
|
|
64
|
+
if (isImpacted) selected.push(filePath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return selected.sort((a, b) => a.localeCompare(b));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
selectSpecFiles,
|
|
72
|
+
extractFixtureUsagesFromSpec,
|
|
73
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'test-impact-core-'));
|
|
9
|
+
|
|
10
|
+
const ensureDirForFile = (filePath) => fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
11
|
+
|
|
12
|
+
const writeFile = (rootDir, relativePath, content) => {
|
|
13
|
+
const filePath = path.join(rootDir, relativePath);
|
|
14
|
+
ensureDirForFile(filePath);
|
|
15
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
16
|
+
return filePath;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const run = (cwd, command, args) => {
|
|
20
|
+
const result = spawnSync(command, args, { cwd, encoding: 'utf8' });
|
|
21
|
+
if (result.status !== 0) {
|
|
22
|
+
const details = (result.stderr || result.stdout || '').trim();
|
|
23
|
+
throw new Error(`${command} ${args.join(' ')} failed: ${details}`);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const initGitRepo = (cwd) => {
|
|
29
|
+
run(cwd, 'git', ['init', '-q']);
|
|
30
|
+
run(cwd, 'git', ['config', 'user.email', 'test@example.com']);
|
|
31
|
+
run(cwd, 'git', ['config', 'user.name', 'Test User']);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const commitAll = (cwd, message) => {
|
|
35
|
+
run(cwd, 'git', ['add', '.']);
|
|
36
|
+
run(cwd, 'git', ['commit', '-q', '-m', message]);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
createTempDir,
|
|
41
|
+
writeFile,
|
|
42
|
+
initGitRepo,
|
|
43
|
+
commitAll,
|
|
44
|
+
run,
|
|
45
|
+
};
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { analyzeImpactedSpecs } = require('../src/analyze-impacted-specs');
|
|
7
|
+
const { createTempDir, writeFile, initGitRepo, commitAll, run } = require('./_test-helpers');
|
|
8
|
+
|
|
9
|
+
const genericProfile = {
|
|
10
|
+
testsRootRelative: 'tests-app',
|
|
11
|
+
changedSpecPrefix: 'tests-app/',
|
|
12
|
+
analysisRootsRelative: ['src/pages', 'src/utils'],
|
|
13
|
+
fixturesTypesRelative: 'src/fixtures/types.ts',
|
|
14
|
+
isRelevantPomPath: (filePath) => filePath.startsWith('src/pages/') && (filePath.endsWith('.ts') || filePath.endsWith('.tsx')),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const profileWithApi = {
|
|
18
|
+
...genericProfile,
|
|
19
|
+
analysisRootsRelative: ['src/pages', 'src/utils', 'src/api'],
|
|
20
|
+
globalWatchMode: 'disabled',
|
|
21
|
+
isRelevantPomPath: (filePath) =>
|
|
22
|
+
(filePath.startsWith('src/pages/') || filePath.startsWith('src/api/')) &&
|
|
23
|
+
(filePath.endsWith('.ts') || filePath.endsWith('.tsx')),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const profileWithApiAllFiles = {
|
|
27
|
+
...genericProfile,
|
|
28
|
+
analysisRootsRelative: ['src/pages', 'src/utils', 'src/api'],
|
|
29
|
+
globalWatchMode: 'disabled',
|
|
30
|
+
isRelevantPomPath: (filePath) =>
|
|
31
|
+
filePath.startsWith('src/pages/') ||
|
|
32
|
+
filePath.startsWith('src/api/'),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const createBaseRepo = () => {
|
|
36
|
+
const dir = createTempDir();
|
|
37
|
+
initGitRepo(dir);
|
|
38
|
+
|
|
39
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { target(){ return 1; } open(){ return this.target(); } }\n');
|
|
40
|
+
writeFile(dir, 'src/fixtures/types.ts', 'type T = {\n myPage: Pages.MyPage;\n};\n');
|
|
41
|
+
writeFile(dir, 'tests-app/basic.spec.ts', 'test("x", async ({ myPage }) => { await myPage.open(); });\n');
|
|
42
|
+
|
|
43
|
+
commitAll(dir, 'base');
|
|
44
|
+
return dir;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
test('analyzeImpactedSpecs returns no work when nothing changed', () => {
|
|
48
|
+
const dir = createBaseRepo();
|
|
49
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, includeUntrackedSpecs: true });
|
|
50
|
+
|
|
51
|
+
assert.equal(result.hasAnythingToRun, false);
|
|
52
|
+
assert.equal(result.selectedSpecs.length, 0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('analyzeImpactedSpecs includes direct changed spec', () => {
|
|
56
|
+
const dir = createBaseRepo();
|
|
57
|
+
writeFile(dir, 'tests-app/basic.spec.ts', 'test("x", async ({ myPage }) => { await myPage.open(); await myPage.open(); });\n');
|
|
58
|
+
|
|
59
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, includeUntrackedSpecs: true });
|
|
60
|
+
|
|
61
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/basic.spec.ts'), true);
|
|
62
|
+
assert.equal(result.selectionReasons.values().next().value, 'direct-changed-spec');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('analyzeImpactedSpecs includes untracked spec when enabled', () => {
|
|
66
|
+
const dir = createBaseRepo();
|
|
67
|
+
writeFile(dir, 'tests-app/newly-added.spec.ts', 'test("x", async ({ myPage }) => { await myPage.open(); });\n');
|
|
68
|
+
|
|
69
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, includeUntrackedSpecs: true });
|
|
70
|
+
|
|
71
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/newly-added.spec.ts'), true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('analyzeImpactedSpecs ignores untracked spec when disabled', () => {
|
|
75
|
+
const dir = createBaseRepo();
|
|
76
|
+
writeFile(dir, 'tests-app/newly-added.spec.ts', 'test("x", async ({ myPage }) => { await myPage.open(); });\n');
|
|
77
|
+
|
|
78
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, includeUntrackedSpecs: false });
|
|
79
|
+
|
|
80
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/newly-added.spec.ts'), false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('analyzeImpactedSpecs includes unstaged source change with baseRef when includeWorkingTreeWithBase=true', () => {
|
|
84
|
+
const dir = createBaseRepo();
|
|
85
|
+
writeFile(dir, 'README.md', '# first\n');
|
|
86
|
+
commitAll(dir, 'second commit');
|
|
87
|
+
|
|
88
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { target(){ return 2; } open(){ return this.target(); } }\n');
|
|
89
|
+
|
|
90
|
+
const withWorkingTree = analyzeImpactedSpecs({
|
|
91
|
+
repoRoot: dir,
|
|
92
|
+
baseRef: 'HEAD~1',
|
|
93
|
+
profile: genericProfile,
|
|
94
|
+
includeWorkingTreeWithBase: true,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const withoutWorkingTree = analyzeImpactedSpecs({
|
|
98
|
+
repoRoot: dir,
|
|
99
|
+
baseRef: 'HEAD~1',
|
|
100
|
+
profile: genericProfile,
|
|
101
|
+
includeWorkingTreeWithBase: false,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.equal(withWorkingTree.hasAnythingToRun, true);
|
|
105
|
+
assert.equal(withoutWorkingTree.hasAnythingToRun, false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('analyzeImpactedSpecs includes staged source change with baseRef when includeWorkingTreeWithBase=true', () => {
|
|
109
|
+
const dir = createBaseRepo();
|
|
110
|
+
writeFile(dir, 'README.md', '# first\n');
|
|
111
|
+
commitAll(dir, 'second commit');
|
|
112
|
+
|
|
113
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { target(){ return 3; } open(){ return this.target(); } }\n');
|
|
114
|
+
run(dir, 'git', ['add', 'src/pages/MyPage.ts']);
|
|
115
|
+
|
|
116
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, baseRef: 'HEAD~1', profile: genericProfile, includeWorkingTreeWithBase: true });
|
|
117
|
+
assert.equal(result.hasAnythingToRun, true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('analyzeImpactedSpecs includes untracked source ts and tsx files', () => {
|
|
121
|
+
const dir = createBaseRepo();
|
|
122
|
+
writeFile(dir, 'src/pages/NewPage.ts', 'export class NewPage { run(){ return 1; } }\n');
|
|
123
|
+
writeFile(dir, 'src/pages/NewPage.tsx', 'export class NewPageTsx { run(){ return 1; } }\n');
|
|
124
|
+
|
|
125
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, includeUntrackedSpecs: true });
|
|
126
|
+
|
|
127
|
+
assert.equal(result.changedEntriesBySource.fromUntracked >= 2, true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('analyzeImpactedSpecs marks uncertain dynamic callsite and keeps spec in fail-open mode', () => {
|
|
131
|
+
const dir = createBaseRepo();
|
|
132
|
+
writeFile(dir, 'tests-app/basic.spec.ts', 'test("x", async ({ myPage }) => { const k = "open"; await myPage[k](); });\n');
|
|
133
|
+
commitAll(dir, 'switch spec to dynamic call');
|
|
134
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { open(){ return 2; } run(){ const key = "open"; return this[key](); } }\n');
|
|
135
|
+
|
|
136
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, selectionBias: 'fail-open' });
|
|
137
|
+
|
|
138
|
+
assert.equal(result.hasAnythingToRun, true);
|
|
139
|
+
assert.equal(result.coverageStats.uncertainCallSites >= 1, true);
|
|
140
|
+
assert.equal(Array.from(result.selectionReasons.values()).includes('matched-uncertain-fail-open'), true);
|
|
141
|
+
assert.equal(result.warnings.length > 0, true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('analyzeImpactedSpecs can drop uncertain-only spec in fail-closed mode', () => {
|
|
145
|
+
const dir = createBaseRepo();
|
|
146
|
+
writeFile(dir, 'tests-app/basic.spec.ts', 'test("x", async ({ myPage }) => { const k = "open"; await myPage[k](); });\n');
|
|
147
|
+
commitAll(dir, 'switch spec to dynamic call');
|
|
148
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { open(){ return 3; } run(){ const key = "open"; return this[key](); } }\n');
|
|
149
|
+
|
|
150
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, selectionBias: 'fail-closed' });
|
|
151
|
+
assert.equal(result.hasAnythingToRun, false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('analyzeImpactedSpecs keeps deterministic order of selected specs', () => {
|
|
155
|
+
const dir = createBaseRepo();
|
|
156
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { open(){ return 2; } target(){ return 2; } }\n');
|
|
157
|
+
writeFile(dir, 'tests-app/z.spec.ts', 'test("z", async ({ myPage }) => { await myPage.open(); });\n');
|
|
158
|
+
writeFile(dir, 'tests-app/a.spec.ts', 'test("a", async ({ myPage }) => { await myPage.open(); });\n');
|
|
159
|
+
|
|
160
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, includeUntrackedSpecs: true });
|
|
161
|
+
const sorted = [...result.selectedSpecsRelative].sort((a, b) => a.localeCompare(b));
|
|
162
|
+
assert.deepEqual(result.selectedSpecsRelative, sorted);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('analyzeImpactedSpecs combined scenario rename + working tree + optional call selects specs', () => {
|
|
166
|
+
const dir = createBaseRepo();
|
|
167
|
+
run(dir, 'git', ['mv', 'src/pages/MyPage.ts', 'src/pages/MyRenamedPage.ts']);
|
|
168
|
+
commitAll(dir, 'rename page');
|
|
169
|
+
|
|
170
|
+
writeFile(dir, 'src/pages/MyRenamedPage.ts', 'export class MyPage { target(){ return 5; } open(){ return this.target(); } }\n');
|
|
171
|
+
writeFile(dir, 'tests-app/basic.spec.ts', 'test("x", async ({ myPage }) => { await myPage?.open?.(); });\n');
|
|
172
|
+
|
|
173
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, baseRef: 'HEAD~1', profile: genericProfile, includeWorkingTreeWithBase: true });
|
|
174
|
+
assert.equal(result.hasAnythingToRun, true);
|
|
175
|
+
assert.equal(result.statusSummary.R >= 1 || result.changedPomEntries.some((entry) => entry.status === 'R'), true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('fail-open selection is never narrower than fail-closed on the same repo state', () => {
|
|
179
|
+
const dir = createBaseRepo();
|
|
180
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { open(){ return 1; } run(){ const key = "open"; return this[key](); } }\n');
|
|
181
|
+
writeFile(dir, 'tests-app/basic.spec.ts', 'test("x", async ({ myPage }) => { const k = "open"; await myPage[k](); });\n');
|
|
182
|
+
|
|
183
|
+
const failOpen = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, selectionBias: 'fail-open' });
|
|
184
|
+
const failClosed = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, selectionBias: 'fail-closed' });
|
|
185
|
+
|
|
186
|
+
assert.equal(failOpen.selectedSpecsRelative.length >= failClosed.selectedSpecsRelative.length, true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('Detect modified POM file (M) and include impacted specs', () => {
|
|
190
|
+
const dir = createBaseRepo();
|
|
191
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { target(){ return 10; } open(){ return this.target(); } }\\n');
|
|
192
|
+
|
|
193
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
194
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.status === 'M'), true);
|
|
195
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/basic.spec.ts'), true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('Detect added POM file (A) and include relevant specs', () => {
|
|
199
|
+
const dir = createBaseRepo();
|
|
200
|
+
writeFile(dir, 'src/pages/ExtraPage.ts', 'export class ExtraPage { run(){ return 1; } }\\n');
|
|
201
|
+
writeFile(dir, 'src/fixtures/types.ts', 'type T = {\\n myPage: Pages.MyPage;\\n extraPage: Pages.ExtraPage;\\n};\\n');
|
|
202
|
+
writeFile(dir, 'tests-app/extra.spec.ts', 'test(\"x\", async ({ extraPage }) => { await extraPage.run(); });\\n');
|
|
203
|
+
|
|
204
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, includeUntrackedSpecs: true });
|
|
205
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.status === 'A'), true);
|
|
206
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/extra.spec.ts'), true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('Detect deleted POM file (D) and include relevant specs', () => {
|
|
210
|
+
const dir = createBaseRepo();
|
|
211
|
+
run(dir, 'git', ['rm', 'src/pages/MyPage.ts']);
|
|
212
|
+
|
|
213
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
214
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.status === 'D'), true);
|
|
215
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/basic.spec.ts'), true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('Detect renamed POM file (R) and keep semantic comparison old->new', () => {
|
|
219
|
+
const dir = createBaseRepo();
|
|
220
|
+
run(dir, 'git', ['mv', 'src/pages/MyPage.ts', 'src/pages/MyRenamedPage.ts']);
|
|
221
|
+
|
|
222
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
223
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.status === 'R'), true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('Rename-only POM change produces no semantic method impact', () => {
|
|
227
|
+
const dir = createBaseRepo();
|
|
228
|
+
run(dir, 'git', ['mv', 'src/pages/MyPage.ts', 'src/pages/MyRenamedPage.ts']);
|
|
229
|
+
|
|
230
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
231
|
+
assert.equal(result.semanticStats.semanticChangedMethodsCount, 0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('Rename + method change detects impacted method correctly', () => {
|
|
235
|
+
const dir = createBaseRepo();
|
|
236
|
+
run(dir, 'git', ['mv', 'src/pages/MyPage.ts', 'src/pages/MyRenamedPage.ts']);
|
|
237
|
+
writeFile(dir, 'src/pages/MyRenamedPage.ts', 'export class MyPage { target(){ return 11; } open(){ return this.target(); } }\\n');
|
|
238
|
+
|
|
239
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
240
|
+
assert.equal(result.semanticStats.semanticChangedMethodsCount >= 1, true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('Move-only POM file change handled as rename/move', () => {
|
|
244
|
+
const dir = createBaseRepo();
|
|
245
|
+
writeFile(dir, 'src/pages/moved/.keep', '');
|
|
246
|
+
run(dir, 'git', ['mv', 'src/pages/MyPage.ts', 'src/pages/moved/MyPage.ts']);
|
|
247
|
+
|
|
248
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
249
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.status === 'R'), true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('Untracked utility file within profile scope impacts selection', () => {
|
|
253
|
+
const dir = createBaseRepo();
|
|
254
|
+
const profileWithUtils = {
|
|
255
|
+
...genericProfile,
|
|
256
|
+
isRelevantPomPath: (filePath) =>
|
|
257
|
+
(filePath.startsWith('src/pages/') || filePath.startsWith('src/utils/')) &&
|
|
258
|
+
(filePath.endsWith('.ts') || filePath.endsWith('.tsx')),
|
|
259
|
+
};
|
|
260
|
+
writeFile(dir, 'src/utils/Helper.ts', 'export class Helper { run(){ return 1; } }\\n');
|
|
261
|
+
|
|
262
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: profileWithUtils });
|
|
263
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.effectivePath === 'src/utils/Helper.ts'), true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('Filter excludes files outside profile scope', () => {
|
|
267
|
+
const dir = createBaseRepo();
|
|
268
|
+
writeFile(dir, 'external/Outside.ts', 'export class Outside {}\\n');
|
|
269
|
+
|
|
270
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
271
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.effectivePath === 'external/Outside.ts'), false);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('fileExtensions default includes .ts and .tsx', () => {
|
|
275
|
+
const dir = createBaseRepo();
|
|
276
|
+
writeFile(dir, 'src/pages/Widget.tsx', 'export class Widget { run(){ return 1; } }\\n');
|
|
277
|
+
|
|
278
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
279
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.effectivePath.endsWith('.tsx')), true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('fileExtensions custom list limits analysis to specified extensions', () => {
|
|
283
|
+
const dir = createBaseRepo();
|
|
284
|
+
writeFile(dir, 'src/pages/Widget.tsx', 'export class Widget { run(){ return 1; } }\\n');
|
|
285
|
+
|
|
286
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, fileExtensions: ['.ts'] });
|
|
287
|
+
assert.equal(result.changedPomEntries.some((entry) => entry.effectivePath.endsWith('.tsx')), false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('Deterministic ordering of reasons per spec is stable across runs', () => {
|
|
291
|
+
const dir = createBaseRepo();
|
|
292
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { target(){ return 12; } open(){ return this.target(); } }\\n');
|
|
293
|
+
|
|
294
|
+
const first = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
295
|
+
const second = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
296
|
+
|
|
297
|
+
assert.deepEqual(Array.from(first.selectionReasons.entries()), Array.from(second.selectionReasons.entries()));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('Same input produces identical output (determinism test)', () => {
|
|
301
|
+
const dir = createBaseRepo();
|
|
302
|
+
writeFile(dir, 'src/pages/MyPage.ts', 'export class MyPage { target(){ return 13; } open(){ return this.target(); } }\\n');
|
|
303
|
+
|
|
304
|
+
const first = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, selectionBias: 'fail-open' });
|
|
305
|
+
const second = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile, selectionBias: 'fail-open' });
|
|
306
|
+
|
|
307
|
+
assert.deepEqual(first.selectedSpecsRelative, second.selectedSpecsRelative);
|
|
308
|
+
assert.deepEqual(first.warnings, second.warnings);
|
|
309
|
+
assert.deepEqual(first.coverageStats, second.coverageStats);
|
|
310
|
+
assert.deepEqual(first.changedEntriesBySource, second.changedEntriesBySource);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('Import graph selects spec for changed helper imported directly by relative path', () => {
|
|
314
|
+
const dir = createBaseRepo();
|
|
315
|
+
writeFile(dir, 'src/api/helpers/user-helper.ts', 'export const getUsers = () => 1;\n');
|
|
316
|
+
writeFile(
|
|
317
|
+
dir,
|
|
318
|
+
'tests-app/import-direct.spec.ts',
|
|
319
|
+
'import { getUsers } from "../src/api/helpers/user-helper"; test("x", async () => { getUsers(); });\n'
|
|
320
|
+
);
|
|
321
|
+
commitAll(dir, 'add helper and import spec');
|
|
322
|
+
|
|
323
|
+
writeFile(dir, 'src/api/helpers/user-helper.ts', 'export const getUsers = () => 2;\n');
|
|
324
|
+
|
|
325
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: profileWithApi, includeUntrackedSpecs: true });
|
|
326
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/import-direct.spec.ts'), true);
|
|
327
|
+
|
|
328
|
+
const absSpec = path.join(dir, 'tests-app/import-direct.spec.ts');
|
|
329
|
+
assert.equal(result.selectionReasons.get(absSpec), 'matched-import-graph');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('Import graph selects spec for changed helper re-exported through alias barrel', () => {
|
|
333
|
+
const dir = createBaseRepo();
|
|
334
|
+
writeFile(
|
|
335
|
+
dir,
|
|
336
|
+
'tsconfig.json',
|
|
337
|
+
[
|
|
338
|
+
'{',
|
|
339
|
+
' "compilerOptions": {',
|
|
340
|
+
' "baseUrl": "./",',
|
|
341
|
+
' "paths": {',
|
|
342
|
+
' "@api/*": ["./src/api/*"],',
|
|
343
|
+
' "@api": ["./src/api"],',
|
|
344
|
+
' },',
|
|
345
|
+
' },',
|
|
346
|
+
'}',
|
|
347
|
+
'',
|
|
348
|
+
].join('\n')
|
|
349
|
+
);
|
|
350
|
+
writeFile(dir, 'src/api/mocks/helpers/setupWizard.mocks.ts', 'export const mockSetup = () => 1;\n');
|
|
351
|
+
writeFile(dir, 'src/api/mocks/index.ts', 'export * from "./helpers/setupWizard.mocks";\n');
|
|
352
|
+
writeFile(
|
|
353
|
+
dir,
|
|
354
|
+
'tests-app/import-barrel.spec.ts',
|
|
355
|
+
'import { mockSetup } from "@api/mocks"; test("x", async () => { mockSetup(); });\n'
|
|
356
|
+
);
|
|
357
|
+
commitAll(dir, 'add alias barrel and spec');
|
|
358
|
+
|
|
359
|
+
writeFile(dir, 'src/api/mocks/helpers/setupWizard.mocks.ts', 'export const mockSetup = () => 2;\n');
|
|
360
|
+
|
|
361
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: profileWithApi, includeUntrackedSpecs: true });
|
|
362
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/import-barrel.spec.ts'), true);
|
|
363
|
+
|
|
364
|
+
const absSpec = path.join(dir, 'tests-app/import-barrel.spec.ts');
|
|
365
|
+
assert.equal(result.selectionReasons.get(absSpec), 'matched-import-graph');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('Import graph selects spec for changed json asset referenced by helper', () => {
|
|
369
|
+
const dir = createBaseRepo();
|
|
370
|
+
writeFile(
|
|
371
|
+
dir,
|
|
372
|
+
'tsconfig.json',
|
|
373
|
+
[
|
|
374
|
+
'{',
|
|
375
|
+
' "compilerOptions": {',
|
|
376
|
+
' "baseUrl": "./",',
|
|
377
|
+
' "paths": {',
|
|
378
|
+
' "@api/*": ["./src/api/*"],',
|
|
379
|
+
' "@api": ["./src/api"],',
|
|
380
|
+
' },',
|
|
381
|
+
' },',
|
|
382
|
+
'}',
|
|
383
|
+
'',
|
|
384
|
+
].join('\n')
|
|
385
|
+
);
|
|
386
|
+
writeFile(
|
|
387
|
+
dir,
|
|
388
|
+
'src/api/mocks/helpers/setupWizard.mocks.ts',
|
|
389
|
+
'export const mockSetup = async (graphqlMock) => graphqlMock.mockOperations("getAcademicPeriods", "getAcademicPeriods.completed.json");\n'
|
|
390
|
+
);
|
|
391
|
+
writeFile(dir, 'src/api/mocks/helpers/getAcademicPeriods.completed.json', '{"items":[1]}\n');
|
|
392
|
+
writeFile(dir, 'src/api/mocks/index.ts', 'export * from "./helpers/setupWizard.mocks";\n');
|
|
393
|
+
writeFile(
|
|
394
|
+
dir,
|
|
395
|
+
'tests-app/import-json-asset.spec.ts',
|
|
396
|
+
'import { mockSetup } from "@api/mocks"; test("x", async ({ graphqlMock }) => { await mockSetup(graphqlMock); });\n'
|
|
397
|
+
);
|
|
398
|
+
commitAll(dir, 'add json mock asset and spec');
|
|
399
|
+
|
|
400
|
+
writeFile(dir, 'src/api/mocks/helpers/getAcademicPeriods.completed.json', '{"items":[2]}\n');
|
|
401
|
+
|
|
402
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: profileWithApiAllFiles, includeUntrackedSpecs: true });
|
|
403
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/import-json-asset.spec.ts'), true);
|
|
404
|
+
|
|
405
|
+
const absSpec = path.join(dir, 'tests-app/import-json-asset.spec.ts');
|
|
406
|
+
assert.equal(result.selectionReasons.get(absSpec), 'matched-import-graph');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('Global watch change in playwright.stem.config.ts forces all project specs', () => {
|
|
410
|
+
const dir = createBaseRepo();
|
|
411
|
+
writeFile(dir, 'playwright.stem.config.ts', 'export default {};\n');
|
|
412
|
+
writeFile(dir, 'tests-app/another.spec.ts', 'test("y", async ({ myPage }) => { await myPage.open(); });\n');
|
|
413
|
+
commitAll(dir, 'add stem config and second spec');
|
|
414
|
+
|
|
415
|
+
writeFile(dir, 'playwright.stem.config.ts', 'export default { retries: 1 };\n');
|
|
416
|
+
|
|
417
|
+
const result = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
418
|
+
assert.equal(result.forcedAllSpecs, true);
|
|
419
|
+
assert.equal(result.forcedAllSpecsReason, 'global-watch-force-all');
|
|
420
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/basic.spec.ts'), true);
|
|
421
|
+
assert.equal(result.selectedSpecsRelative.includes('tests-app/another.spec.ts'), true);
|
|
422
|
+
assert.equal(Array.from(result.selectionReasons.values()).every((reason) => reason === 'global-watch-force-all'), true);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('Global watch change in src/global-setup-mn.ts forces all mathnation specs', () => {
|
|
426
|
+
const dir = createTempDir();
|
|
427
|
+
initGitRepo(dir);
|
|
428
|
+
writeFile(dir, 'src/global-setup-mn.ts', 'export default async () => {};\n');
|
|
429
|
+
writeFile(dir, 'tests-mathnation/a.spec.ts', 'test("a", async () => {});\n');
|
|
430
|
+
writeFile(dir, 'tests-mathnation/b.spec.ts', 'test("b", async () => {});\n');
|
|
431
|
+
writeFile(dir, 'src/fixtures/types.ts', 'type T = {};\n');
|
|
432
|
+
commitAll(dir, 'base mn');
|
|
433
|
+
|
|
434
|
+
writeFile(dir, 'src/global-setup-mn.ts', 'export default async () => { return 1; };\n');
|
|
435
|
+
|
|
436
|
+
const result = analyzeImpactedSpecs({
|
|
437
|
+
repoRoot: dir,
|
|
438
|
+
profile: {
|
|
439
|
+
...genericProfile,
|
|
440
|
+
testsRootRelative: 'tests-mathnation',
|
|
441
|
+
changedSpecPrefix: 'tests-mathnation/',
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
assert.equal(result.forcedAllSpecs, true);
|
|
445
|
+
assert.deepEqual(result.selectedSpecsRelative, ['tests-mathnation/a.spec.ts', 'tests-mathnation/b.spec.ts']);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('Global watch can be disabled and falls back to selective behavior', () => {
|
|
449
|
+
const dir = createBaseRepo();
|
|
450
|
+
writeFile(dir, 'playwright.stem.config.ts', 'export default {};\n');
|
|
451
|
+
commitAll(dir, 'add config');
|
|
452
|
+
writeFile(dir, 'playwright.stem.config.ts', 'export default { retries: 2 };\n');
|
|
453
|
+
|
|
454
|
+
const result = analyzeImpactedSpecs({
|
|
455
|
+
repoRoot: dir,
|
|
456
|
+
profile: {
|
|
457
|
+
...genericProfile,
|
|
458
|
+
globalWatchMode: 'disabled',
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
assert.equal(result.forcedAllSpecs, false);
|
|
462
|
+
assert.equal(result.hasAnythingToRun, false);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('Global watch force-all output is deterministic across runs', () => {
|
|
466
|
+
const dir = createBaseRepo();
|
|
467
|
+
writeFile(dir, 'src/global-setup-stem.ts', 'export default async () => {};\n');
|
|
468
|
+
writeFile(dir, 'tests-app/aaa.spec.ts', 'test("a", async ({ myPage }) => { await myPage.open(); });\n');
|
|
469
|
+
commitAll(dir, 'base global setup');
|
|
470
|
+
writeFile(dir, 'src/global-setup-stem.ts', 'export default async () => { return true; };\n');
|
|
471
|
+
|
|
472
|
+
const first = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
473
|
+
const second = analyzeImpactedSpecs({ repoRoot: dir, profile: genericProfile });
|
|
474
|
+
|
|
475
|
+
assert.deepEqual(first.selectedSpecsRelative, second.selectedSpecsRelative);
|
|
476
|
+
assert.deepEqual(Array.from(first.selectionReasons.entries()), Array.from(second.selectionReasons.entries()));
|
|
477
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { analyzeImpactedSpecs } = require('../src/analyze-impacted-specs');
|
|
6
|
+
|
|
7
|
+
test('analyzeImpactedSpecs validates required inputs', () => {
|
|
8
|
+
assert.throws(() => analyzeImpactedSpecs({}), /Missing required profile configuration/);
|
|
9
|
+
|
|
10
|
+
assert.throws(
|
|
11
|
+
() =>
|
|
12
|
+
analyzeImpactedSpecs({
|
|
13
|
+
repoRoot: process.cwd(),
|
|
14
|
+
profile: { testsRootRelative: 'tests', changedSpecPrefix: 'tests/' },
|
|
15
|
+
}),
|
|
16
|
+
/Missing profile\.isRelevantPomPath/
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('analyzeImpactedSpecs accepts new option fields without throwing', () => {
|
|
21
|
+
assert.throws(
|
|
22
|
+
() =>
|
|
23
|
+
analyzeImpactedSpecs({
|
|
24
|
+
repoRoot: '',
|
|
25
|
+
profile: {
|
|
26
|
+
testsRootRelative: 'tests',
|
|
27
|
+
changedSpecPrefix: 'tests/',
|
|
28
|
+
isRelevantPomPath: () => true,
|
|
29
|
+
},
|
|
30
|
+
includeWorkingTreeWithBase: true,
|
|
31
|
+
fileExtensions: ['.ts', '.tsx'],
|
|
32
|
+
selectionBias: 'fail-open',
|
|
33
|
+
}),
|
|
34
|
+
/Missing required repoRoot/
|
|
35
|
+
);
|
|
36
|
+
});
|