@aplaytest/heal 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/dist/.tsbuildinfo +1 -0
- package/dist/candidates.d.ts +76 -0
- package/dist/candidates.d.ts.map +1 -0
- package/dist/candidates.js +211 -0
- package/dist/candidates.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +75 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +154 -0
- package/dist/ledger.js.map +1 -0
- package/dist/patch.d.ts +52 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +120 -0
- package/dist/patch.js.map +1 -0
- package/dist/propose.d.ts +111 -0
- package/dist/propose.d.ts.map +1 -0
- package/dist/propose.js +218 -0
- package/dist/propose.js.map +1 -0
- package/dist/resolve.d.ts +25 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +87 -0
- package/dist/resolve.js.map +1 -0
- package/dist/validate.d.ts +50 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +157 -0
- package/dist/validate.js.map +1 -0
- package/package.json +39 -0
package/dist/propose.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The heal pipeline, end to end.
|
|
3
|
+
*
|
|
4
|
+
* Order is load-bearing, and two of the gates are the whole reason this is
|
|
5
|
+
* defensible at all:
|
|
6
|
+
*
|
|
7
|
+
* 1. NEVER_HEAL kinds are refused outright. A schema violation or an
|
|
8
|
+
* uncaught application error IS the bug; "repairing" it would delete the
|
|
9
|
+
* most valuable signal a suite produces.
|
|
10
|
+
* 2. Known-flaky tests are refused. Healing a flake is the worst outcome
|
|
11
|
+
* available: a permanent code change made to chase noise, the flake
|
|
12
|
+
* continues, and now the selector is wrong too. Flaky and broken are
|
|
13
|
+
* different problems, and the engine must decide which it is FIRST.
|
|
14
|
+
*
|
|
15
|
+
* Only then does it generate candidates, and only then does Playwright decide.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import { isAbsolute, join } from 'node:path';
|
|
19
|
+
import { NEVER_HEAL, ROUTING, isHealable, parseLocator, } from '@aplaytest/core';
|
|
20
|
+
import { assessBundle, generateCandidates, missingTestIds, DEFAULT_CANDIDATE_OPTIONS, } from './candidates.js';
|
|
21
|
+
import { patchConstant } from './patch.js';
|
|
22
|
+
import { DEFAULT_HEAL_TARGET_GLOBS, resolveSelectorSource } from './resolve.js';
|
|
23
|
+
import { validateHeal } from './validate.js';
|
|
24
|
+
export const DEFAULT_HEAL_OPTIONS = {
|
|
25
|
+
validationRuns: 3,
|
|
26
|
+
checkCollateral: true,
|
|
27
|
+
flakeThreshold: 0.15,
|
|
28
|
+
};
|
|
29
|
+
async function resolvePatchSource(options, value) {
|
|
30
|
+
if (options.constantsFile !== undefined && options.constantsText !== undefined) {
|
|
31
|
+
return { file: options.constantsFile, text: options.constantsText };
|
|
32
|
+
}
|
|
33
|
+
if (options.constantsFile !== undefined) {
|
|
34
|
+
const path = isAbsolute(options.constantsFile)
|
|
35
|
+
? options.constantsFile
|
|
36
|
+
: join(options.cwd, options.constantsFile);
|
|
37
|
+
const text = await readFile(path, 'utf8').catch(() => null);
|
|
38
|
+
if (text === null)
|
|
39
|
+
return null;
|
|
40
|
+
return { file: options.constantsFile, text };
|
|
41
|
+
}
|
|
42
|
+
const resolved = await resolveSelectorSource({
|
|
43
|
+
cwd: options.cwd,
|
|
44
|
+
value,
|
|
45
|
+
globs: options.targetGlobs ?? DEFAULT_HEAL_TARGET_GLOBS,
|
|
46
|
+
});
|
|
47
|
+
if (resolved === null)
|
|
48
|
+
return null;
|
|
49
|
+
return { file: resolved.file, text: resolved.text };
|
|
50
|
+
}
|
|
51
|
+
function refuse(bundle, status, reason, candidates = []) {
|
|
52
|
+
return {
|
|
53
|
+
status,
|
|
54
|
+
evidenceId: bundle.id,
|
|
55
|
+
testTitle: bundle.test.title,
|
|
56
|
+
reason,
|
|
57
|
+
intendedSelector: bundle.intent.selector,
|
|
58
|
+
candidates,
|
|
59
|
+
chosen: null,
|
|
60
|
+
patch: null,
|
|
61
|
+
validation: null,
|
|
62
|
+
tierOne: null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run the heal pipeline against one evidence bundle.
|
|
67
|
+
*
|
|
68
|
+
* Order is load-bearing: `NEVER_HEAL` kinds and known-flaky tests are refused
|
|
69
|
+
* before any candidate is generated. Playwright, not the ranking score,
|
|
70
|
+
* decides whether a patch is proposable.
|
|
71
|
+
*
|
|
72
|
+
* @param bundle - Captured failure (selector, ARIA, test-id index).
|
|
73
|
+
* @param options - Working directory, spec to re-run, and validation policy.
|
|
74
|
+
* @returns A proposal whose `status` is `proposed`, `rejected`, or one of the
|
|
75
|
+
* `refused-*` / `no-*` outcomes. Never throws on a refused heal.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* const proposal = await proposeHeal(bundle, {
|
|
80
|
+
* cwd: process.cwd(),
|
|
81
|
+
* specFile: 'tests/gyms.spec.ts',
|
|
82
|
+
* validationRuns: 3,
|
|
83
|
+
* checkCollateral: true,
|
|
84
|
+
* });
|
|
85
|
+
* if (proposal.status === 'proposed') {
|
|
86
|
+
* console.log(proposal.chosen?.value);
|
|
87
|
+
* }
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export async function proposeHeal(bundle, options) {
|
|
91
|
+
const kind = bundle.failure.kind;
|
|
92
|
+
// Gate 1 — the hard guard. Not a policy setting; not overridable.
|
|
93
|
+
if (NEVER_HEAL.has(kind) || !isHealable(kind)) {
|
|
94
|
+
return refuse(bundle, 'refused-never-heal', `${kind}: ${ROUTING[kind].note} Healing is refused for this failure kind.`);
|
|
95
|
+
}
|
|
96
|
+
// Gate 2 — flaky before broken. Changing code to chase noise leaves you with
|
|
97
|
+
// the noise AND a wrong selector.
|
|
98
|
+
const threshold = options.flakeThreshold ?? DEFAULT_HEAL_OPTIONS.flakeThreshold;
|
|
99
|
+
if (options.flakeScore !== undefined && options.flakeScore > threshold) {
|
|
100
|
+
return refuse(bundle, 'refused-flaky', `This test scores ${options.flakeScore.toFixed(2)} for flakiness (threshold ${threshold}). ` +
|
|
101
|
+
'Healing a flake changes code permanently to chase noise. Bisect it first.');
|
|
102
|
+
}
|
|
103
|
+
const candidateOptions = {
|
|
104
|
+
...(options.candidateOptions ?? DEFAULT_CANDIDATE_OPTIONS),
|
|
105
|
+
...(options.allowedStrategies === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: { allowedStrategies: options.allowedStrategies }),
|
|
108
|
+
};
|
|
109
|
+
const eligibility = assessBundle(bundle, candidateOptions);
|
|
110
|
+
if (!eligibility.eligible) {
|
|
111
|
+
return refuse(bundle, 'refused-ineligible', eligibility.reason);
|
|
112
|
+
}
|
|
113
|
+
const candidates = generateCandidates(bundle, candidateOptions);
|
|
114
|
+
if (candidates.length === 0) {
|
|
115
|
+
return refuse(bundle, 'no-candidates', `${eligibility.reason}, and no test id on the page is close enough to be a plausible rename. ` +
|
|
116
|
+
'The element was probably removed — that is a real change, not a drifted selector.');
|
|
117
|
+
}
|
|
118
|
+
let chosen = candidates[0];
|
|
119
|
+
if (chosen === undefined)
|
|
120
|
+
return refuse(bundle, 'no-candidates', 'no candidate survived ranking');
|
|
121
|
+
// The id to replace is the MISSING one, not the first one the selector
|
|
122
|
+
// mentions: a composite locator names its container first, and the
|
|
123
|
+
// container is usually the id that still exists.
|
|
124
|
+
const parsed = parseLocator(bundle.intent.selector);
|
|
125
|
+
const intendedValue = missingTestIds(bundle.intent.selector, bundle.page.testIdsPresent)[0] ??
|
|
126
|
+
parsed?.accessibleName ??
|
|
127
|
+
(parsed !== null && parsed.strategy !== 'testid' ? parsed.value : undefined);
|
|
128
|
+
if (intendedValue === undefined) {
|
|
129
|
+
return refuse(bundle, 'refused-ineligible', 'could not identify which locator value is missing');
|
|
130
|
+
}
|
|
131
|
+
// Tier 1: reorder by intent, if a ranker is wired in. It can only pick from
|
|
132
|
+
// the candidates Tier 0 already verified, and its pick is validated exactly
|
|
133
|
+
// the same way — so the worst a wrong answer costs is a rejected proposal.
|
|
134
|
+
let tierOne = null;
|
|
135
|
+
if (options.rankCandidates !== undefined) {
|
|
136
|
+
const ranked = await options.rankCandidates({ candidates, missingTestId: intendedValue });
|
|
137
|
+
tierOne = {
|
|
138
|
+
used: ranked.used,
|
|
139
|
+
model: ranked.model,
|
|
140
|
+
outcome: ranked.outcome,
|
|
141
|
+
reasoning: ranked.reasoning,
|
|
142
|
+
confidence: ranked.confidence,
|
|
143
|
+
usd: ranked.usd,
|
|
144
|
+
changedChoice: ranked.chosen !== null && ranked.chosen !== chosen.value,
|
|
145
|
+
};
|
|
146
|
+
if (ranked.realBug) {
|
|
147
|
+
return {
|
|
148
|
+
...refuse(bundle, 'refused-never-heal', 'The ranker judged this an application defect rather than a renamed selector. ' +
|
|
149
|
+
'Healing is refused; file a bug.', candidates),
|
|
150
|
+
tierOne,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const preferred = candidates.find(c => c.value === ranked.chosen);
|
|
154
|
+
if (preferred !== undefined)
|
|
155
|
+
chosen = preferred;
|
|
156
|
+
}
|
|
157
|
+
const source = await resolvePatchSource(options, intendedValue);
|
|
158
|
+
if (source === null) {
|
|
159
|
+
return {
|
|
160
|
+
...refuse(bundle, 'no-constant', 'No selector source found. Pass --constants, or add the file to heal.targets ' +
|
|
161
|
+
'(constants, page objects, and specs are searched by default).', candidates),
|
|
162
|
+
chosen,
|
|
163
|
+
tierOne,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const patch = patchConstant(source.text, {
|
|
167
|
+
file: source.file,
|
|
168
|
+
from: intendedValue,
|
|
169
|
+
to: chosen.value,
|
|
170
|
+
});
|
|
171
|
+
if (patch.status !== 'applied' || patch.after === null) {
|
|
172
|
+
return {
|
|
173
|
+
...refuse(bundle, 'no-constant', patch.message, candidates),
|
|
174
|
+
chosen,
|
|
175
|
+
patch,
|
|
176
|
+
tierOne,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (options.skipValidation === true) {
|
|
180
|
+
return {
|
|
181
|
+
status: 'proposed',
|
|
182
|
+
evidenceId: bundle.id,
|
|
183
|
+
testTitle: bundle.test.title,
|
|
184
|
+
reason: `${eligibility.reason}. NOT VALIDATED — re-run required before this can be accepted.`,
|
|
185
|
+
intendedSelector: bundle.intent.selector,
|
|
186
|
+
candidates,
|
|
187
|
+
chosen,
|
|
188
|
+
patch,
|
|
189
|
+
validation: null,
|
|
190
|
+
tierOne,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const validation = await validateHeal({
|
|
194
|
+
cwd: options.cwd,
|
|
195
|
+
specFile: options.specFile,
|
|
196
|
+
testTitle: bundle.test.title,
|
|
197
|
+
patchFile: source.file,
|
|
198
|
+
patchedText: patch.after,
|
|
199
|
+
config: options.config,
|
|
200
|
+
project: options.project,
|
|
201
|
+
runs: options.validationRuns,
|
|
202
|
+
checkCollateral: options.checkCollateral,
|
|
203
|
+
timeoutMs: options.timeoutMs,
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
status: validation.status === 'validated' ? 'proposed' : 'rejected',
|
|
207
|
+
evidenceId: bundle.id,
|
|
208
|
+
testTitle: bundle.test.title,
|
|
209
|
+
reason: validation.message,
|
|
210
|
+
intendedSelector: bundle.intent.selector,
|
|
211
|
+
candidates,
|
|
212
|
+
chosen,
|
|
213
|
+
patch,
|
|
214
|
+
validation,
|
|
215
|
+
tierOne,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=propose.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"propose.js","sourceRoot":"","sources":["../src/propose.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the file that actually holds a drifted locator.
|
|
3
|
+
*
|
|
4
|
+
* Healing used to require `--constants` because the first suite kept every
|
|
5
|
+
* selector in `*.constants.ts`. Suites also inline literals in page objects
|
|
6
|
+
* and specs (`getByTestId('page-header')`). The engine walks configured globs
|
|
7
|
+
* and prefers the most reviewable target.
|
|
8
|
+
*/
|
|
9
|
+
import { type TouchedConstant } from './patch.js';
|
|
10
|
+
export declare const DEFAULT_HEAL_TARGET_GLOBS: readonly ["src/**/*.constants.ts", "src/**/*.page.ts", "src/**/*.section.ts", "tests/**/*.spec.ts", "tests/**/*.test.ts"];
|
|
11
|
+
export type HealTargetKind = 'constants' | 'page-object' | 'spec';
|
|
12
|
+
export interface ResolvedSelectorSource {
|
|
13
|
+
readonly kind: HealTargetKind;
|
|
14
|
+
readonly file: string;
|
|
15
|
+
readonly text: string;
|
|
16
|
+
readonly hits: readonly TouchedConstant[];
|
|
17
|
+
}
|
|
18
|
+
export declare function classifyHealTarget(file: string): HealTargetKind;
|
|
19
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
20
|
+
export declare function resolveSelectorSource(input: {
|
|
21
|
+
readonly cwd: string;
|
|
22
|
+
readonly value: string;
|
|
23
|
+
readonly globs?: readonly string[] | undefined;
|
|
24
|
+
}): Promise<ResolvedSelectorSource | null>;
|
|
25
|
+
//# sourceMappingURL=resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,EAAgB,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAEhE,eAAO,MAAM,yBAAyB,2HAM5B,CAAC;AAEX,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,aAAa,GAAG,MAAM,CAAC;AAElE,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,SAAS,eAAe,EAAE,CAAC;CAC3C;AAUD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,CAK/D;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CASjD;AAkBD,wBAAsB,qBAAqB,CAAC,KAAK,EAAE;IACjD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;CAChD,GAAG,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAyBzC"}
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the file that actually holds a drifted locator.
|
|
3
|
+
*
|
|
4
|
+
* Healing used to require `--constants` because the first suite kept every
|
|
5
|
+
* selector in `*.constants.ts`. Suites also inline literals in page objects
|
|
6
|
+
* and specs (`getByTestId('page-header')`). The engine walks configured globs
|
|
7
|
+
* and prefers the most reviewable target.
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
10
|
+
import { join, relative } from 'node:path';
|
|
11
|
+
import { findConstant } from './patch.js';
|
|
12
|
+
export const DEFAULT_HEAL_TARGET_GLOBS = [
|
|
13
|
+
'src/**/*.constants.ts',
|
|
14
|
+
'src/**/*.page.ts',
|
|
15
|
+
'src/**/*.section.ts',
|
|
16
|
+
'tests/**/*.spec.ts',
|
|
17
|
+
'tests/**/*.test.ts',
|
|
18
|
+
];
|
|
19
|
+
const KIND_RANK = {
|
|
20
|
+
constants: 0,
|
|
21
|
+
'page-object': 1,
|
|
22
|
+
spec: 2,
|
|
23
|
+
};
|
|
24
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-cjs', '.git', 'test-results', 'playwright-report']);
|
|
25
|
+
export function classifyHealTarget(file) {
|
|
26
|
+
const normalized = file.replace(/\\/g, '/');
|
|
27
|
+
if (/\.constants\.ts$/.test(normalized) || /\/constants\//.test(normalized))
|
|
28
|
+
return 'constants';
|
|
29
|
+
if (/\.(?:page|section)\.ts$/.test(normalized))
|
|
30
|
+
return 'page-object';
|
|
31
|
+
return 'spec';
|
|
32
|
+
}
|
|
33
|
+
export function globToRegExp(glob) {
|
|
34
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
35
|
+
const withBraces = escaped.replace(/\{([^}]+)\}/g, (_, inner) => {
|
|
36
|
+
const options = inner.split(',').map(part => part.trim()).join('|');
|
|
37
|
+
return `(?:${options})`;
|
|
38
|
+
});
|
|
39
|
+
const withDouble = withBraces.replace(/\*\*/g, '\u0000');
|
|
40
|
+
const withSingle = withDouble.replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*');
|
|
41
|
+
return new RegExp(`^${withSingle}$`);
|
|
42
|
+
}
|
|
43
|
+
async function walkFiles(root) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry.name.startsWith('.') && entry.name !== '.')
|
|
48
|
+
continue;
|
|
49
|
+
const path = join(root, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (SKIP_DIRS.has(entry.name))
|
|
52
|
+
continue;
|
|
53
|
+
out.push(...(await walkFiles(path)));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (entry.isFile() && entry.name.endsWith('.ts'))
|
|
57
|
+
out.push(path);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
export async function resolveSelectorSource(input) {
|
|
62
|
+
const globs = input.globs ?? DEFAULT_HEAL_TARGET_GLOBS;
|
|
63
|
+
const patterns = globs.map(globToRegExp);
|
|
64
|
+
const files = await walkFiles(input.cwd);
|
|
65
|
+
const matches = [];
|
|
66
|
+
for (const absolute of files) {
|
|
67
|
+
const relativePath = relative(input.cwd, absolute).split('\\').join('/');
|
|
68
|
+
if (!patterns.some(pattern => pattern.test(relativePath)))
|
|
69
|
+
continue;
|
|
70
|
+
const text = await readFile(absolute, 'utf8').catch(() => null);
|
|
71
|
+
if (text === null)
|
|
72
|
+
continue;
|
|
73
|
+
const hits = findConstant(text, relativePath, input.value);
|
|
74
|
+
if (hits.length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
matches.push({
|
|
77
|
+
kind: classifyHealTarget(relativePath),
|
|
78
|
+
file: relativePath,
|
|
79
|
+
text,
|
|
80
|
+
hits,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (matches.length === 0)
|
|
84
|
+
return null;
|
|
85
|
+
return [...matches].sort((a, b) => KIND_RANK[a.kind] - KIND_RANK[b.kind])[0] ?? null;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation: Playwright decides, not the score.
|
|
3
|
+
*
|
|
4
|
+
* A candidate's score orders the queue. Whether a heal is ACCEPTED is settled
|
|
5
|
+
* by re-running the failing test with the patch applied — N times, plus the
|
|
6
|
+
* rest of the spec file. Confidence never authorises a change.
|
|
7
|
+
*
|
|
8
|
+
* The collateral check is the one that catches the seductive-but-wrong heal.
|
|
9
|
+
* Retargeting a constant to a container element can make one assertion pass
|
|
10
|
+
* while quietly breaking the other nine tests that read the same constant —
|
|
11
|
+
* and a patch that passes its own test while breaking its neighbours is
|
|
12
|
+
* exactly what an unattended healing loop would merrily commit.
|
|
13
|
+
*/
|
|
14
|
+
export type ValidationStatus = 'validated' | 'target-still-failing' | 'collateral-damage' | 'inconclusive';
|
|
15
|
+
export interface ValidationRecord {
|
|
16
|
+
readonly status: ValidationStatus;
|
|
17
|
+
readonly runs: number;
|
|
18
|
+
readonly targetPassed: number;
|
|
19
|
+
readonly targetTotal: number;
|
|
20
|
+
readonly collateralPassed: number;
|
|
21
|
+
/** Tests that passed BEFORE the patch and fail after — the ones that matter. */
|
|
22
|
+
readonly collateralRegressed: readonly string[];
|
|
23
|
+
/** Tests already failing before the patch; not the patch's fault. */
|
|
24
|
+
readonly preexistingFailures: readonly string[];
|
|
25
|
+
readonly message: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ValidateOptions {
|
|
28
|
+
readonly cwd: string;
|
|
29
|
+
/** Spec file containing the failing test. */
|
|
30
|
+
readonly specFile: string;
|
|
31
|
+
readonly testTitle: string;
|
|
32
|
+
/** File the patch rewrites (constants, page object, or spec). */
|
|
33
|
+
readonly patchFile: string;
|
|
34
|
+
readonly patchedText: string;
|
|
35
|
+
readonly config?: string | undefined;
|
|
36
|
+
readonly project?: string | undefined;
|
|
37
|
+
readonly runs: number;
|
|
38
|
+
readonly checkCollateral: boolean;
|
|
39
|
+
readonly timeoutMs?: number | undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Apply the patch, run, restore — always.
|
|
43
|
+
*
|
|
44
|
+
* The original is moved aside rather than held in memory so that a crash
|
|
45
|
+
* mid-validation leaves a recoverable file on disk instead of a half-healed
|
|
46
|
+
* repository. Restoration runs in `finally`, and the backup is only removed
|
|
47
|
+
* once the original is definitely back.
|
|
48
|
+
*/
|
|
49
|
+
export declare function validateHeal(options: ValidateOptions): Promise<ValidationRecord>;
|
|
50
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,MAAM,MAAM,gBAAgB,GACxB,WAAW,GACX,sBAAsB,GACtB,mBAAmB,GACnB,cAAc,CAAC;AAEnB,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,gFAAgF;IAChF,QAAQ,CAAC,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;IAChD,qEAAqE;IACrE,QAAQ,CAAC,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;IAClC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACzC;AAID;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA+ItF"}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation: Playwright decides, not the score.
|
|
3
|
+
*
|
|
4
|
+
* A candidate's score orders the queue. Whether a heal is ACCEPTED is settled
|
|
5
|
+
* by re-running the failing test with the patch applied — N times, plus the
|
|
6
|
+
* rest of the spec file. Confidence never authorises a change.
|
|
7
|
+
*
|
|
8
|
+
* The collateral check is the one that catches the seductive-but-wrong heal.
|
|
9
|
+
* Retargeting a constant to a container element can make one assertion pass
|
|
10
|
+
* while quietly breaking the other nine tests that read the same constant —
|
|
11
|
+
* and a patch that passes its own test while breaking its neighbours is
|
|
12
|
+
* exactly what an unattended healing loop would merrily commit.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { runPlaywright } from '@aplaytest/runner-playwright';
|
|
16
|
+
const BACKUP_SUFFIX = '.atest-backup';
|
|
17
|
+
/**
|
|
18
|
+
* Apply the patch, run, restore — always.
|
|
19
|
+
*
|
|
20
|
+
* The original is moved aside rather than held in memory so that a crash
|
|
21
|
+
* mid-validation leaves a recoverable file on disk instead of a half-healed
|
|
22
|
+
* repository. Restoration runs in `finally`, and the backup is only removed
|
|
23
|
+
* once the original is definitely back.
|
|
24
|
+
*/
|
|
25
|
+
export async function validateHeal(options) {
|
|
26
|
+
const backupPath = `${options.patchFile}${BACKUP_SUFFIX}`;
|
|
27
|
+
const original = await readFile(options.patchFile, 'utf8');
|
|
28
|
+
// BASELINE FIRST, before the patch exists.
|
|
29
|
+
//
|
|
30
|
+
// Without it, any test already failing in this file — for reasons that have
|
|
31
|
+
// nothing to do with the selector — reads as damage the patch caused, and no
|
|
32
|
+
// heal in that file could ever be proposed. Only tests that PASSED before
|
|
33
|
+
// and fail after are the patch's doing. The cost is one extra run of the
|
|
34
|
+
// file, which is the honest price of not blaming a patch for what it did
|
|
35
|
+
// not break.
|
|
36
|
+
const baselineFailures = new Set();
|
|
37
|
+
if (options.checkCollateral) {
|
|
38
|
+
const baseline = await runPlaywright({
|
|
39
|
+
cwd: options.cwd,
|
|
40
|
+
config: options.config,
|
|
41
|
+
file: options.specFile,
|
|
42
|
+
project: options.project,
|
|
43
|
+
timeoutMs: options.timeoutMs,
|
|
44
|
+
});
|
|
45
|
+
for (const spec of baseline.specs) {
|
|
46
|
+
if (spec.failed > 0)
|
|
47
|
+
baselineFailures.add(spec.title);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await writeFile(backupPath, original, 'utf8');
|
|
51
|
+
await writeFile(options.patchFile, options.patchedText, 'utf8');
|
|
52
|
+
try {
|
|
53
|
+
const target = await runPlaywright({
|
|
54
|
+
cwd: options.cwd,
|
|
55
|
+
config: options.config,
|
|
56
|
+
file: options.specFile,
|
|
57
|
+
grepTitle: options.testTitle,
|
|
58
|
+
project: options.project,
|
|
59
|
+
repeatEach: options.runs,
|
|
60
|
+
timeoutMs: options.timeoutMs,
|
|
61
|
+
});
|
|
62
|
+
const targetSpecs = target.specs.filter(s => s.title === options.testTitle);
|
|
63
|
+
const targetPassed = targetSpecs.reduce((sum, s) => sum + s.passed, 0);
|
|
64
|
+
const targetFailed = targetSpecs.reduce((sum, s) => sum + s.failed, 0);
|
|
65
|
+
const targetTotal = targetPassed + targetFailed;
|
|
66
|
+
if (target.inconclusive || targetTotal === 0) {
|
|
67
|
+
return {
|
|
68
|
+
status: 'inconclusive',
|
|
69
|
+
runs: options.runs,
|
|
70
|
+
targetPassed: 0,
|
|
71
|
+
targetTotal: 0,
|
|
72
|
+
collateralPassed: 0,
|
|
73
|
+
collateralRegressed: [],
|
|
74
|
+
preexistingFailures: [...baselineFailures],
|
|
75
|
+
message: 'The target test did not run under the patch — check the title, file and project. ' +
|
|
76
|
+
'An unverified patch is not a heal.',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Must pass EVERY time. One flake here means the heal is unproven, not
|
|
80
|
+
// "mostly working" — and shipping an unproven heal is how a suite starts
|
|
81
|
+
// lying about what it tests.
|
|
82
|
+
if (targetPassed !== targetTotal) {
|
|
83
|
+
return {
|
|
84
|
+
status: 'target-still-failing',
|
|
85
|
+
runs: options.runs,
|
|
86
|
+
targetPassed,
|
|
87
|
+
targetTotal,
|
|
88
|
+
collateralPassed: 0,
|
|
89
|
+
collateralRegressed: [],
|
|
90
|
+
preexistingFailures: [...baselineFailures],
|
|
91
|
+
message: `The patched test passed ${targetPassed}/${targetTotal} times. A heal must pass every time.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!options.checkCollateral) {
|
|
95
|
+
return {
|
|
96
|
+
status: 'validated',
|
|
97
|
+
runs: options.runs,
|
|
98
|
+
targetPassed,
|
|
99
|
+
targetTotal,
|
|
100
|
+
collateralPassed: 0,
|
|
101
|
+
collateralRegressed: [],
|
|
102
|
+
preexistingFailures: [],
|
|
103
|
+
message: `Target passed ${targetPassed}/${targetTotal}. Collateral check skipped.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const whole = await runPlaywright({
|
|
107
|
+
cwd: options.cwd,
|
|
108
|
+
config: options.config,
|
|
109
|
+
file: options.specFile,
|
|
110
|
+
project: options.project,
|
|
111
|
+
timeoutMs: options.timeoutMs,
|
|
112
|
+
});
|
|
113
|
+
const others = whole.specs.filter(s => s.title !== options.testTitle);
|
|
114
|
+
const collateralPassed = others.reduce((sum, s) => sum + s.passed, 0);
|
|
115
|
+
// Only NEW failures are the patch's doing.
|
|
116
|
+
const regressed = others
|
|
117
|
+
.filter(s => s.failed > 0 && !baselineFailures.has(s.title))
|
|
118
|
+
.map(s => s.title);
|
|
119
|
+
const preexisting = others.filter(s => baselineFailures.has(s.title)).map(s => s.title);
|
|
120
|
+
if (regressed.length > 0) {
|
|
121
|
+
return {
|
|
122
|
+
status: 'collateral-damage',
|
|
123
|
+
runs: options.runs,
|
|
124
|
+
targetPassed,
|
|
125
|
+
targetTotal,
|
|
126
|
+
collateralPassed,
|
|
127
|
+
collateralRegressed: regressed,
|
|
128
|
+
preexistingFailures: preexisting,
|
|
129
|
+
message: `The patch fixes its own test but breaks ${regressed.length} that passed before it: ` +
|
|
130
|
+
`${regressed.join(', ')}. Rejected.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
status: 'validated',
|
|
135
|
+
runs: options.runs,
|
|
136
|
+
targetPassed,
|
|
137
|
+
targetTotal,
|
|
138
|
+
collateralPassed,
|
|
139
|
+
collateralRegressed: [],
|
|
140
|
+
preexistingFailures: preexisting,
|
|
141
|
+
message: `Target passed ${targetPassed}/${targetTotal}; ${collateralPassed} neighbouring test ` +
|
|
142
|
+
`results unchanged` +
|
|
143
|
+
(preexisting.length > 0
|
|
144
|
+
? ` (${preexisting.length} were already failing before the patch and still are)`
|
|
145
|
+
: ''),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await writeFile(options.patchFile, original, 'utf8').catch(async () => {
|
|
150
|
+
// Writing back failed — fall back to moving the backup into place rather
|
|
151
|
+
// than leaving the repository patched.
|
|
152
|
+
await rename(backupPath, options.patchFile).catch(() => undefined);
|
|
153
|
+
});
|
|
154
|
+
await unlink(backupPath).catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aplaytest/heal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic selector healing: candidates, validation, and constants patching",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc --build",
|
|
14
|
+
"test": "vitest run"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@aplaytest/core": "^0.1.0",
|
|
18
|
+
"@aplaytest/runner-playwright": "^0.1.0",
|
|
19
|
+
"ts-morph": "^28.0.0"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/ianoflynnautomation/aplaytest.git",
|
|
33
|
+
"directory": "packages/heal"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/ianoflynnautomation/aplaytest#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/ianoflynnautomation/aplaytest/issues"
|
|
38
|
+
}
|
|
39
|
+
}
|