donobu 2.46.6 → 2.47.1
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/assets/generated/version +1 -1
- package/dist/cli/donobu-cli.d.ts +3 -0
- package/dist/cli/donobu-cli.d.ts.map +1 -0
- package/dist/cli/donobu-cli.js +1698 -0
- package/dist/cli/donobu-cli.js.map +1 -0
- package/dist/cli/playwright-json-to-markdown.js +43 -22
- package/dist/cli/playwright-json-to-markdown.js.map +1 -1
- package/dist/envVars.d.ts +23 -0
- package/dist/envVars.d.ts.map +1 -1
- package/dist/envVars.js +13 -0
- package/dist/envVars.js.map +1 -1
- package/dist/esm/assets/generated/version +1 -1
- package/dist/esm/cli/donobu-cli.d.ts +3 -0
- package/dist/esm/cli/donobu-cli.d.ts.map +1 -0
- package/dist/esm/cli/donobu-cli.js +1698 -0
- package/dist/esm/cli/donobu-cli.js.map +1 -0
- package/dist/esm/cli/playwright-json-to-markdown.js +43 -22
- package/dist/esm/cli/playwright-json-to-markdown.js.map +1 -1
- package/dist/esm/envVars.d.ts +23 -0
- package/dist/esm/envVars.d.ts.map +1 -1
- package/dist/esm/envVars.js +13 -0
- package/dist/esm/envVars.js.map +1 -1
- package/dist/esm/lib/DonobuExtendedPage.d.ts +3 -0
- package/dist/esm/lib/DonobuExtendedPage.d.ts.map +1 -1
- package/dist/esm/lib/PageAi.d.ts.map +1 -1
- package/dist/esm/lib/PageAi.js +7 -1
- package/dist/esm/lib/PageAi.js.map +1 -1
- package/dist/esm/lib/testExtension.d.ts.map +1 -1
- package/dist/esm/lib/testExtension.js +53 -9
- package/dist/esm/lib/testExtension.js.map +1 -1
- package/dist/esm/lib/utils/triageTestFailure.d.ts +231 -0
- package/dist/esm/lib/utils/triageTestFailure.d.ts.map +1 -0
- package/dist/esm/lib/utils/triageTestFailure.js +1267 -0
- package/dist/esm/lib/utils/triageTestFailure.js.map +1 -0
- package/dist/lib/DonobuExtendedPage.d.ts +3 -0
- package/dist/lib/DonobuExtendedPage.d.ts.map +1 -1
- package/dist/lib/PageAi.d.ts.map +1 -1
- package/dist/lib/PageAi.js +7 -1
- package/dist/lib/PageAi.js.map +1 -1
- package/dist/lib/testExtension.d.ts.map +1 -1
- package/dist/lib/testExtension.js +53 -9
- package/dist/lib/testExtension.js.map +1 -1
- package/dist/lib/utils/triageTestFailure.d.ts +231 -0
- package/dist/lib/utils/triageTestFailure.d.ts.map +1 -0
- package/dist/lib/utils/triageTestFailure.js +1267 -0
- package/dist/lib/utils/triageTestFailure.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,1698 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
/**
|
|
38
|
+
* @fileoverview Donobu Test Runner CLI shim.
|
|
39
|
+
*
|
|
40
|
+
* Extends the Playwright CLI with Donobu-specific hooks. In addition to proxying
|
|
41
|
+
* `npx playwright test`, the shim orchestrates failure triage by collecting
|
|
42
|
+
* evidence during the test run and generating treatment plans once execution
|
|
43
|
+
* completes.
|
|
44
|
+
*
|
|
45
|
+
* Current behavior:
|
|
46
|
+
* - Accepts the `test` sub-command (default when omitted) plus any Playwright flags.
|
|
47
|
+
* - Passes Donobu-specific flags `--no-triage`, `--triage-output-dir`, `--clear-ai-cache`,
|
|
48
|
+
* and `--auto-heal`.
|
|
49
|
+
* - Supports `npx donobu heal --plan <file>` for applying previously generated treatment plans.
|
|
50
|
+
* - Captures failure evidence into a run-scoped directory and, when possible,
|
|
51
|
+
* generates treatment plans after Playwright exits.
|
|
52
|
+
* - Invokes Playwright via `npx playwright ...`, streaming stdout/stderr directly.
|
|
53
|
+
* - Propagates exit codes and termination signals so CI pipelines behave like the
|
|
54
|
+
* upstream Playwright CLI.
|
|
55
|
+
*/
|
|
56
|
+
const child_process_1 = require("child_process");
|
|
57
|
+
const crypto_1 = require("crypto");
|
|
58
|
+
const fs_1 = require("fs");
|
|
59
|
+
const path = __importStar(require("path"));
|
|
60
|
+
const os = __importStar(require("os"));
|
|
61
|
+
const v4_1 = require("zod/v4");
|
|
62
|
+
const gptClients_1 = require("../lib/fixtures/gptClients");
|
|
63
|
+
const triageTestFailure_1 = require("../lib/utils/triageTestFailure");
|
|
64
|
+
const Logger_1 = require("../utils/Logger");
|
|
65
|
+
const FAILURE_EVIDENCE_PREFIX = 'failure-evidence-';
|
|
66
|
+
const TREATMENT_PLAN_PREFIX = 'treatment-plan-';
|
|
67
|
+
const PLAYWRIGHT_JSON_REPORT_FILENAME = 'report.json';
|
|
68
|
+
/**
|
|
69
|
+
* Execute `npx playwright` while wiring Donobu-specific environment controls.
|
|
70
|
+
* Streams stdout/stderr to the current process so our CLI mirrors the native
|
|
71
|
+
* Playwright experience, and forwards termination signals to keep CI parity.
|
|
72
|
+
*/
|
|
73
|
+
async function runPlaywright(args, envOverrides = {}) {
|
|
74
|
+
Logger_1.appLogger.debug(`Running Playwright with args: ${JSON.stringify(args)}`);
|
|
75
|
+
if (Object.keys(envOverrides).length > 0) {
|
|
76
|
+
Logger_1.appLogger.debug(`Playwright env overrides: ${JSON.stringify(envOverrides, null, 2)}`);
|
|
77
|
+
}
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const childEnv = { ...process.env, ...envOverrides };
|
|
80
|
+
const child = (0, child_process_1.spawn)('npx', ['playwright', ...args], {
|
|
81
|
+
stdio: 'inherit',
|
|
82
|
+
shell: process.platform === 'win32',
|
|
83
|
+
env: childEnv,
|
|
84
|
+
});
|
|
85
|
+
child.once('error', (error) => {
|
|
86
|
+
reject(error);
|
|
87
|
+
});
|
|
88
|
+
child.once('close', (code, signal) => {
|
|
89
|
+
if (signal) {
|
|
90
|
+
process.kill(process.pid, signal);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
resolve(code ?? 0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Peel off Donobu-managed flags from the CLI invocation while keeping the rest
|
|
99
|
+
* untouched so they can be forwarded directly to Playwright.
|
|
100
|
+
*
|
|
101
|
+
* Supports both `--flag value` and `--flag=value` syntaxes because folks often
|
|
102
|
+
* copy commands from CI logs where spacing varies.
|
|
103
|
+
*/
|
|
104
|
+
function parseDonobuArgs(args) {
|
|
105
|
+
const passthroughArgs = [];
|
|
106
|
+
let triageEnabled = true;
|
|
107
|
+
let triageOutputDir;
|
|
108
|
+
let clearAiCache = false;
|
|
109
|
+
let autoHeal = false;
|
|
110
|
+
let passthroughMode = false;
|
|
111
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
112
|
+
const arg = args[i];
|
|
113
|
+
if (passthroughMode) {
|
|
114
|
+
passthroughArgs.push(arg);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (arg === '--') {
|
|
118
|
+
passthroughArgs.push(arg);
|
|
119
|
+
passthroughMode = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (arg === '--no-triage') {
|
|
123
|
+
triageEnabled = false;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (arg === '--clear-ai-cache') {
|
|
127
|
+
clearAiCache = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (arg === '--auto-heal') {
|
|
131
|
+
autoHeal = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (arg === '--triage-output-dir') {
|
|
135
|
+
const value = args[i + 1];
|
|
136
|
+
if (value) {
|
|
137
|
+
triageOutputDir = value;
|
|
138
|
+
i += 1;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
Logger_1.appLogger.warn('Missing value for --triage-output-dir; ignoring flag.');
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (arg.startsWith('--triage-output-dir=')) {
|
|
146
|
+
const [, value] = arg.split('=', 2);
|
|
147
|
+
if (value) {
|
|
148
|
+
triageOutputDir = value;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
Logger_1.appLogger.warn('Missing value for --triage-output-dir; ignoring flag.');
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
passthroughArgs.push(arg);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
passthroughArgs,
|
|
159
|
+
options: {
|
|
160
|
+
triageEnabled,
|
|
161
|
+
triageOutputDir,
|
|
162
|
+
clearAiCache,
|
|
163
|
+
autoHeal,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Playwright expects the first argument to be a sub-command like `test`. Users
|
|
169
|
+
* can omit it when using Donobu, so we normalize the argv before proxying.
|
|
170
|
+
*/
|
|
171
|
+
function normalizePlaywrightArgs(args) {
|
|
172
|
+
if (args.length === 0) {
|
|
173
|
+
return ['test'];
|
|
174
|
+
}
|
|
175
|
+
const [first, ...rest] = args;
|
|
176
|
+
if (first === 'test') {
|
|
177
|
+
return ['test', ...rest];
|
|
178
|
+
}
|
|
179
|
+
if (first.startsWith('-')) {
|
|
180
|
+
return ['test', first, ...rest];
|
|
181
|
+
}
|
|
182
|
+
Logger_1.appLogger.error(`Unsupported command "${first}". Expected "test".`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
function parseTestCommandArgs(rawArgs) {
|
|
186
|
+
/**
|
|
187
|
+
* `npx donobu test ...` and `npx donobu ...` both route here. We strip off the
|
|
188
|
+
* optional explicit `test` token, parse Donobu flags, then rebuild the argv
|
|
189
|
+
* we intend to hand to Playwright.
|
|
190
|
+
*/
|
|
191
|
+
let args = rawArgs;
|
|
192
|
+
let explicitTestCommand = false;
|
|
193
|
+
if (args[0] === 'test') {
|
|
194
|
+
explicitTestCommand = true;
|
|
195
|
+
args = args.slice(1);
|
|
196
|
+
}
|
|
197
|
+
const { passthroughArgs, options } = parseDonobuArgs(args);
|
|
198
|
+
const normalized = normalizePlaywrightArgs(explicitTestCommand ? ['test', ...passthroughArgs] : passthroughArgs);
|
|
199
|
+
return {
|
|
200
|
+
options,
|
|
201
|
+
playwrightArgs: normalized,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Playwright writes all artifacts under `--output`. If the user does not supply
|
|
206
|
+
* it we default to `<repo>/test-results`, matching Playwright's behaviour.
|
|
207
|
+
*/
|
|
208
|
+
function resolvePlaywrightOutputDir(playwrightArgs) {
|
|
209
|
+
let outputDir;
|
|
210
|
+
for (let i = 0; i < playwrightArgs.length; i += 1) {
|
|
211
|
+
const arg = playwrightArgs[i];
|
|
212
|
+
if (arg === '--output' || arg === '-o') {
|
|
213
|
+
outputDir = playwrightArgs[i + 1];
|
|
214
|
+
i += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (arg.startsWith('--output=')) {
|
|
218
|
+
outputDir = arg.slice('--output='.length);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!outputDir) {
|
|
222
|
+
return path.resolve(process.cwd(), 'test-results');
|
|
223
|
+
}
|
|
224
|
+
return path.isAbsolute(outputDir)
|
|
225
|
+
? outputDir
|
|
226
|
+
: path.resolve(process.cwd(), outputDir);
|
|
227
|
+
}
|
|
228
|
+
function buildTriageRunId() {
|
|
229
|
+
const isoSafe = new Date().toISOString().replace(/[:.]/g, '-');
|
|
230
|
+
return `${isoSafe}-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Prepare the folder structure Donobu expects for collecting evidence during a
|
|
234
|
+
* Playwright run. This is invoked for both regular test runs and heal retries.
|
|
235
|
+
*/
|
|
236
|
+
async function prepareTriageContext(playwrightOutputDir, options) {
|
|
237
|
+
const outputBaseDir = options.triageOutputDir
|
|
238
|
+
? path.resolve(options.triageOutputDir)
|
|
239
|
+
: path.join(playwrightOutputDir, 'donobu-triage');
|
|
240
|
+
const runId = buildTriageRunId();
|
|
241
|
+
const runDir = path.join(outputBaseDir, runId);
|
|
242
|
+
await fs_1.promises.mkdir(runDir, { recursive: true });
|
|
243
|
+
return { runId, runDir, outputBaseDir };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Translate runtime flags into environment variables consumed by lower layers.
|
|
247
|
+
* Using env vars keeps the boundaries loose: the Playwright test harness can
|
|
248
|
+
* detect triage/auto-heal mode without taking a dependency on this CLI.
|
|
249
|
+
*/
|
|
250
|
+
function createRunEnvOverrides(params) {
|
|
251
|
+
const envOverrides = {};
|
|
252
|
+
if (params.clearAiCache) {
|
|
253
|
+
envOverrides.DONOBU_PAGE_AI_CLEAR_CACHE = '1';
|
|
254
|
+
}
|
|
255
|
+
if (params.triageEnabled && params.triageContext) {
|
|
256
|
+
envOverrides.DONOBU_TRIAGE_RUN_DIR = params.triageContext.runDir;
|
|
257
|
+
envOverrides.DONOBU_TRIAGE_RUN_ID = params.triageContext.runId;
|
|
258
|
+
envOverrides.DONOBU_TRIAGE_OUTPUT_BASE_DIR =
|
|
259
|
+
params.triageContext.outputBaseDir;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
envOverrides.DONOBU_TRIAGE_DISABLED = '1';
|
|
263
|
+
}
|
|
264
|
+
return envOverrides;
|
|
265
|
+
}
|
|
266
|
+
async function ensureDirectory(dirPath) {
|
|
267
|
+
await fs_1.promises.mkdir(dirPath, { recursive: true });
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Allocate a temporary sandbox for the auto-heal rerun. All transient
|
|
271
|
+
* Playwright output and triage artifacts live here until we copy the pieces we
|
|
272
|
+
* want to keep back into the workspace.
|
|
273
|
+
*/
|
|
274
|
+
async function createAutoHealStagingArea() {
|
|
275
|
+
const rootDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'donobu-auto-heal-'));
|
|
276
|
+
const playwrightOutputDir = path.join(rootDir, 'playwright-output');
|
|
277
|
+
const triageBaseDir = path.join(rootDir, 'triage');
|
|
278
|
+
await ensureDirectory(playwrightOutputDir);
|
|
279
|
+
await ensureDirectory(triageBaseDir);
|
|
280
|
+
const dispose = async () => {
|
|
281
|
+
try {
|
|
282
|
+
await fs_1.promises.rm(rootDir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
Logger_1.appLogger.warn(`Failed to clean up auto-heal staging directory ${rootDir}.`, error);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
return {
|
|
289
|
+
rootDir,
|
|
290
|
+
playwrightOutputDir,
|
|
291
|
+
triageBaseDir,
|
|
292
|
+
dispose,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const noopAsync = async () => { };
|
|
296
|
+
/**
|
|
297
|
+
* Ensure Playwright receives a JSON reporter without clobbering reporters
|
|
298
|
+
* defined via CLI flags or the project's Playwright config. Creates a temporary
|
|
299
|
+
* wrapper config when necessary so we can append the JSON reporter alongside
|
|
300
|
+
* user-specified reporters.
|
|
301
|
+
*/
|
|
302
|
+
async function ensureJsonReporter(originalArgs, options = {}) {
|
|
303
|
+
const args = [...originalArgs];
|
|
304
|
+
if (injectJsonReporterIntoArgs(args, options)) {
|
|
305
|
+
return { args, cleanup: noopAsync };
|
|
306
|
+
}
|
|
307
|
+
const configPath = await resolvePlaywrightConfigPath(args);
|
|
308
|
+
if (!configPath) {
|
|
309
|
+
if (!hasReporterArg(args)) {
|
|
310
|
+
const reporterValue = options.jsonOutputFile
|
|
311
|
+
? `json=${options.jsonOutputFile}`
|
|
312
|
+
: 'json';
|
|
313
|
+
args.push(`--reporter=${reporterValue}`);
|
|
314
|
+
}
|
|
315
|
+
Logger_1.appLogger.debug('No Playwright config detected; falling back to CLI --reporter=json injection.');
|
|
316
|
+
return { args, cleanup: noopAsync };
|
|
317
|
+
}
|
|
318
|
+
const { configPath: wrapperPath, cleanup } = await createConfigWrapperWithJsonReporter(configPath, options);
|
|
319
|
+
Logger_1.appLogger.debug(`Augmenting Playwright config at ${configPath} with temporary wrapper ${wrapperPath} to ensure JSON reporter.`);
|
|
320
|
+
const strippedArgs = stripConfigArgs(args);
|
|
321
|
+
const finalArgs = insertConfigArg(strippedArgs, wrapperPath);
|
|
322
|
+
return { args: finalArgs, cleanup };
|
|
323
|
+
}
|
|
324
|
+
function hasReporterArg(args) {
|
|
325
|
+
for (const arg of args) {
|
|
326
|
+
if (arg === '--') {
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
if (arg === '--reporter' ||
|
|
330
|
+
arg === '-r' ||
|
|
331
|
+
arg.startsWith('--reporter=') ||
|
|
332
|
+
arg.startsWith('-r=')) {
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
function injectJsonReporterIntoArgs(args, options = {}) {
|
|
339
|
+
let reporterFlagFound = false;
|
|
340
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
341
|
+
const arg = args[i];
|
|
342
|
+
if (arg === '--') {
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
if (arg === '--reporter' || arg === '-r') {
|
|
346
|
+
reporterFlagFound = true;
|
|
347
|
+
const valueIndex = i + 1;
|
|
348
|
+
if (valueIndex < args.length) {
|
|
349
|
+
const { value, changed } = ensureReporterValueHasJson(args[valueIndex], options);
|
|
350
|
+
args[valueIndex] = value;
|
|
351
|
+
if (changed) {
|
|
352
|
+
reporterFlagFound = true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
i += 1;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (arg.startsWith('--reporter=') || arg.startsWith('-r=')) {
|
|
359
|
+
reporterFlagFound = true;
|
|
360
|
+
const [prefix, rawValue] = arg.split('=', 2);
|
|
361
|
+
const { value } = ensureReporterValueHasJson(rawValue ?? '', options);
|
|
362
|
+
args[i] = `${prefix}=${value}`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return reporterFlagFound;
|
|
366
|
+
}
|
|
367
|
+
function ensureReporterValueHasJson(value, options = {}) {
|
|
368
|
+
const segments = value
|
|
369
|
+
.split(',')
|
|
370
|
+
.map((segment) => segment.trim())
|
|
371
|
+
.filter((segment) => segment.length > 0);
|
|
372
|
+
const hasJson = segments.some((segment) => segment.split('=')[0].trim() === 'json');
|
|
373
|
+
if (hasJson) {
|
|
374
|
+
return { value, changed: false };
|
|
375
|
+
}
|
|
376
|
+
const jsonEntry = options.jsonOutputFile
|
|
377
|
+
? `json=${options.jsonOutputFile}`
|
|
378
|
+
: 'json';
|
|
379
|
+
segments.push(jsonEntry);
|
|
380
|
+
return { value: segments.join(','), changed: true };
|
|
381
|
+
}
|
|
382
|
+
async function resolvePlaywrightConfigPath(args) {
|
|
383
|
+
const fromArgs = extractConfigPathFromArgs(args);
|
|
384
|
+
if (fromArgs) {
|
|
385
|
+
return path.isAbsolute(fromArgs)
|
|
386
|
+
? fromArgs
|
|
387
|
+
: path.resolve(process.cwd(), fromArgs);
|
|
388
|
+
}
|
|
389
|
+
const candidates = [
|
|
390
|
+
'playwright.config.ts',
|
|
391
|
+
'playwright.config.mts',
|
|
392
|
+
'playwright.config.cts',
|
|
393
|
+
'playwright.config.js',
|
|
394
|
+
'playwright.config.mjs',
|
|
395
|
+
'playwright.config.cjs',
|
|
396
|
+
];
|
|
397
|
+
for (const candidate of candidates) {
|
|
398
|
+
try {
|
|
399
|
+
const resolved = path.resolve(process.cwd(), candidate);
|
|
400
|
+
await fs_1.promises.access(resolved, fs_1.constants.F_OK);
|
|
401
|
+
Logger_1.appLogger.debug(`Detected Playwright config candidate at ${resolved} for JSON reporter injection.`);
|
|
402
|
+
return resolved;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// continue scanning other candidates
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
function extractConfigPathFromArgs(args) {
|
|
411
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
412
|
+
const arg = args[i];
|
|
413
|
+
if (arg === '--') {
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
if (arg === '--config' || arg === '-c') {
|
|
417
|
+
const value = args[i + 1];
|
|
418
|
+
return value ?? null;
|
|
419
|
+
}
|
|
420
|
+
if (arg.startsWith('--config=') || arg.startsWith('-c=')) {
|
|
421
|
+
return arg.split('=', 2)[1] ?? null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
function stripConfigArgs(args) {
|
|
427
|
+
const result = [];
|
|
428
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
429
|
+
const arg = args[i];
|
|
430
|
+
if (arg === '--') {
|
|
431
|
+
result.push(...args.slice(i));
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
if (arg === '--config' || arg === '-c') {
|
|
435
|
+
i += 1;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (arg.startsWith('--config=') || arg.startsWith('-c=')) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
result.push(arg);
|
|
442
|
+
}
|
|
443
|
+
return result;
|
|
444
|
+
}
|
|
445
|
+
function insertConfigArg(args, configPath) {
|
|
446
|
+
const dashDashIndex = args.indexOf('--');
|
|
447
|
+
if (dashDashIndex === -1) {
|
|
448
|
+
return [...args, '--config', configPath];
|
|
449
|
+
}
|
|
450
|
+
return [
|
|
451
|
+
...args.slice(0, dashDashIndex),
|
|
452
|
+
'--config',
|
|
453
|
+
configPath,
|
|
454
|
+
...args.slice(dashDashIndex),
|
|
455
|
+
];
|
|
456
|
+
}
|
|
457
|
+
async function createConfigWrapperWithJsonReporter(originalConfigPath, options = {}) {
|
|
458
|
+
const stagingDir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'donobu-playwright-config-'));
|
|
459
|
+
const wrapperPath = path.join(stagingDir, 'playwright.config.cjs');
|
|
460
|
+
const content = buildConfigWrapperContent(originalConfigPath, options.jsonOutputFile);
|
|
461
|
+
await fs_1.promises.writeFile(wrapperPath, content, 'utf-8');
|
|
462
|
+
const cleanup = async () => {
|
|
463
|
+
try {
|
|
464
|
+
await fs_1.promises.rm(stagingDir, { recursive: true, force: true });
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
Logger_1.appLogger.warn(`Failed to remove temporary Playwright config at ${stagingDir}.`, error);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
return { configPath: wrapperPath, cleanup };
|
|
471
|
+
}
|
|
472
|
+
function buildConfigWrapperContent(originalConfigPath, jsonOutputFileOverride) {
|
|
473
|
+
const sanitisedPath = originalConfigPath.replace(/\\/g, '\\\\');
|
|
474
|
+
const forcedJsonPath = jsonOutputFileOverride
|
|
475
|
+
? jsonOutputFileOverride.replace(/\\/g, '\\\\')
|
|
476
|
+
: null;
|
|
477
|
+
const defaultJsonName = PLAYWRIGHT_JSON_REPORT_FILENAME;
|
|
478
|
+
const forcedLiteral = forcedJsonPath ? `'${forcedJsonPath}'` : 'null';
|
|
479
|
+
return `"use strict";
|
|
480
|
+
|
|
481
|
+
const path = require('path');
|
|
482
|
+
const forcedJsonOutputFile = ${forcedLiteral};
|
|
483
|
+
|
|
484
|
+
function loadBaseConfig() {
|
|
485
|
+
const imported = require('${sanitisedPath}');
|
|
486
|
+
return imported && imported.__esModule ? imported.default : imported;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const configDir = path.dirname('${sanitisedPath}');
|
|
490
|
+
const originalCwd = process.cwd();
|
|
491
|
+
let baseConfig;
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
process.chdir(configDir);
|
|
495
|
+
baseConfig = loadBaseConfig();
|
|
496
|
+
} finally {
|
|
497
|
+
process.chdir(originalCwd);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const normalizedConfig = { ...baseConfig };
|
|
501
|
+
|
|
502
|
+
function absolutify(value) {
|
|
503
|
+
if (typeof value !== 'string') {
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (path.isAbsolute(value)) {
|
|
508
|
+
return value;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return path.resolve(configDir, value);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (normalizedConfig.testDir) {
|
|
515
|
+
normalizedConfig.testDir = absolutify(normalizedConfig.testDir);
|
|
516
|
+
} else {
|
|
517
|
+
normalizedConfig.testDir = configDir;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (normalizedConfig.outputDir) {
|
|
521
|
+
normalizedConfig.outputDir = absolutify(normalizedConfig.outputDir);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (normalizedConfig.snapshotDir) {
|
|
525
|
+
normalizedConfig.snapshotDir = absolutify(normalizedConfig.snapshotDir);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (normalizedConfig.expect && typeof normalizedConfig.expect === 'object') {
|
|
529
|
+
const expectConfig = { ...normalizedConfig.expect };
|
|
530
|
+
if (expectConfig.outputDir) {
|
|
531
|
+
expectConfig.outputDir = absolutify(expectConfig.outputDir);
|
|
532
|
+
}
|
|
533
|
+
if (expectConfig.snapshotDir) {
|
|
534
|
+
expectConfig.snapshotDir = absolutify(expectConfig.snapshotDir);
|
|
535
|
+
}
|
|
536
|
+
normalizedConfig.expect = expectConfig;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (Array.isArray(normalizedConfig.projects)) {
|
|
540
|
+
normalizedConfig.projects = normalizedConfig.projects.map((project) => {
|
|
541
|
+
const projectConfig = { ...project };
|
|
542
|
+
if (projectConfig.testDir) {
|
|
543
|
+
projectConfig.testDir = absolutify(projectConfig.testDir);
|
|
544
|
+
}
|
|
545
|
+
if (projectConfig.outputDir) {
|
|
546
|
+
projectConfig.outputDir = absolutify(projectConfig.outputDir);
|
|
547
|
+
}
|
|
548
|
+
if (projectConfig.snapshotDir) {
|
|
549
|
+
projectConfig.snapshotDir = absolutify(projectConfig.snapshotDir);
|
|
550
|
+
}
|
|
551
|
+
if (
|
|
552
|
+
projectConfig.use &&
|
|
553
|
+
typeof projectConfig.use === 'object' &&
|
|
554
|
+
projectConfig.use !== null
|
|
555
|
+
) {
|
|
556
|
+
const useConfig = { ...projectConfig.use };
|
|
557
|
+
if (typeof useConfig.storageState === 'string') {
|
|
558
|
+
useConfig.storageState = absolutify(useConfig.storageState);
|
|
559
|
+
}
|
|
560
|
+
if (
|
|
561
|
+
typeof useConfig.baseURL === 'string' &&
|
|
562
|
+
!/^https?:/i.test(useConfig.baseURL)
|
|
563
|
+
) {
|
|
564
|
+
useConfig.baseURL = absolutify(useConfig.baseURL);
|
|
565
|
+
}
|
|
566
|
+
projectConfig.use = useConfig;
|
|
567
|
+
}
|
|
568
|
+
return projectConfig;
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const reporters = Array.isArray(normalizedConfig.reporter)
|
|
573
|
+
? normalizedConfig.reporter.map((entry) =>
|
|
574
|
+
Array.isArray(entry) ? [...entry] : entry,
|
|
575
|
+
)
|
|
576
|
+
: normalizedConfig.reporter
|
|
577
|
+
? [normalizedConfig.reporter]
|
|
578
|
+
: [];
|
|
579
|
+
|
|
580
|
+
const hasJsonReporter = reporters.some((entry) => {
|
|
581
|
+
if (!entry) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
if (typeof entry === 'string') {
|
|
585
|
+
return entry
|
|
586
|
+
.split(',')
|
|
587
|
+
.map((segment) => segment.trim())
|
|
588
|
+
.filter((segment) => segment.length > 0)
|
|
589
|
+
.some((segment) => segment.split('=')[0] === 'json');
|
|
590
|
+
}
|
|
591
|
+
if (Array.isArray(entry) && entry.length > 0) {
|
|
592
|
+
const name = typeof entry[0] === 'string' ? entry[0] : '';
|
|
593
|
+
return name === 'json';
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
if (!hasJsonReporter) {
|
|
599
|
+
const outputFile =
|
|
600
|
+
forcedJsonOutputFile ||
|
|
601
|
+
(() => {
|
|
602
|
+
const outputDir = process.env.PLAYWRIGHT_JSON_OUTPUT_DIR
|
|
603
|
+
? path.resolve(process.cwd(), process.env.PLAYWRIGHT_JSON_OUTPUT_DIR)
|
|
604
|
+
: path.resolve(configDir, 'test-results');
|
|
605
|
+
const outputName =
|
|
606
|
+
process.env.PLAYWRIGHT_JSON_OUTPUT_NAME || '${defaultJsonName}';
|
|
607
|
+
return path.isAbsolute(outputName)
|
|
608
|
+
? outputName
|
|
609
|
+
: path.join(outputDir, outputName);
|
|
610
|
+
})();
|
|
611
|
+
reporters.push(['json', { outputFile }]);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const normalisedReporters = reporters.map((entry) => {
|
|
615
|
+
if (typeof entry === 'string') {
|
|
616
|
+
if (!forcedJsonOutputFile) {
|
|
617
|
+
return entry;
|
|
618
|
+
}
|
|
619
|
+
const segments = entry
|
|
620
|
+
.split(',')
|
|
621
|
+
.map((segment) => segment.trim())
|
|
622
|
+
.filter((segment) => segment.length > 0);
|
|
623
|
+
const rewritten = segments.map((segment) => {
|
|
624
|
+
const [name] = segment.split('=', 2);
|
|
625
|
+
if (name === 'json') {
|
|
626
|
+
return \`json=\${forcedJsonOutputFile}\`;
|
|
627
|
+
}
|
|
628
|
+
return segment;
|
|
629
|
+
});
|
|
630
|
+
return rewritten.join(',');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (
|
|
634
|
+
Array.isArray(entry) &&
|
|
635
|
+
entry.length > 1 &&
|
|
636
|
+
entry[1] &&
|
|
637
|
+
typeof entry[1] === 'object'
|
|
638
|
+
) {
|
|
639
|
+
const options = { ...entry[1] };
|
|
640
|
+
if (options.outputFile) {
|
|
641
|
+
options.outputFile = absolutify(options.outputFile);
|
|
642
|
+
}
|
|
643
|
+
if (options.outputFolder) {
|
|
644
|
+
options.outputFolder = absolutify(options.outputFolder);
|
|
645
|
+
}
|
|
646
|
+
if (entry[0] === 'json' && forcedJsonOutputFile) {
|
|
647
|
+
options.outputFile = forcedJsonOutputFile;
|
|
648
|
+
}
|
|
649
|
+
return [entry[0], options];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (Array.isArray(entry) && entry.length > 0) {
|
|
653
|
+
const name = typeof entry[0] === 'string' ? entry[0] : '';
|
|
654
|
+
if (name === 'json' && forcedJsonOutputFile) {
|
|
655
|
+
return [entry[0], { outputFile: forcedJsonOutputFile }];
|
|
656
|
+
}
|
|
657
|
+
return entry;
|
|
658
|
+
}
|
|
659
|
+
return entry;
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
module.exports = {
|
|
663
|
+
...normalizedConfig,
|
|
664
|
+
reporter: normalisedReporters,
|
|
665
|
+
};
|
|
666
|
+
`;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Copy the canonical Playwright JSON report into a Donobu-controlled location.
|
|
670
|
+
* We keep a stable snapshot because Playwright may delete the file between
|
|
671
|
+
* retries or when the `--output` folder is cleaned.
|
|
672
|
+
*/
|
|
673
|
+
async function copyJsonReport(outputDir, destinationPath, options = {}) {
|
|
674
|
+
const candidatePaths = new Set();
|
|
675
|
+
const envDefinedPath = resolveEnvJsonReportPath(options.envOverrides);
|
|
676
|
+
if (envDefinedPath) {
|
|
677
|
+
candidatePaths.add(envDefinedPath);
|
|
678
|
+
}
|
|
679
|
+
candidatePaths.add(path.join(outputDir, PLAYWRIGHT_JSON_REPORT_FILENAME));
|
|
680
|
+
(options.additionalCandidates ?? []).forEach((candidate) => {
|
|
681
|
+
if (candidate) {
|
|
682
|
+
candidatePaths.add(candidate);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
for (const sourcePath of candidatePaths) {
|
|
686
|
+
const copied = await tryCopyReport(sourcePath, destinationPath);
|
|
687
|
+
if (copied) {
|
|
688
|
+
return { sourcePath, destinationPath };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const fallbackSource = await findJsonReportInDir(outputDir);
|
|
692
|
+
if (fallbackSource) {
|
|
693
|
+
const copied = await tryCopyReport(fallbackSource, destinationPath);
|
|
694
|
+
if (copied) {
|
|
695
|
+
return { sourcePath: fallbackSource, destinationPath };
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
function resolveEnvJsonReportPath(envOverrides) {
|
|
701
|
+
if (!envOverrides) {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
const outputDir = envOverrides.PLAYWRIGHT_JSON_OUTPUT_DIR;
|
|
705
|
+
const outputName = envOverrides.PLAYWRIGHT_JSON_OUTPUT_NAME;
|
|
706
|
+
if (!outputDir || !outputName) {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
const resolvedDir = path.isAbsolute(outputDir)
|
|
710
|
+
? outputDir
|
|
711
|
+
: path.resolve(process.cwd(), outputDir);
|
|
712
|
+
return path.isAbsolute(outputName)
|
|
713
|
+
? outputName
|
|
714
|
+
: path.join(resolvedDir, outputName);
|
|
715
|
+
}
|
|
716
|
+
async function tryCopyReport(sourcePath, destinationPath) {
|
|
717
|
+
try {
|
|
718
|
+
await fs_1.promises.access(sourcePath, fs_1.constants.F_OK);
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
await ensureDirectory(path.dirname(destinationPath));
|
|
724
|
+
try {
|
|
725
|
+
await fs_1.promises.copyFile(sourcePath, destinationPath);
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
Logger_1.appLogger.warn(`Failed to copy Playwright JSON report from ${sourcePath} to ${destinationPath}.`, error);
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
async function findJsonReportInDir(outputDir) {
|
|
734
|
+
let entries;
|
|
735
|
+
try {
|
|
736
|
+
entries = await fs_1.promises.readdir(outputDir);
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
const candidates = entries
|
|
742
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
743
|
+
.sort((a, b) => {
|
|
744
|
+
const aScore = a.includes('report') ? 0 : 1;
|
|
745
|
+
const bScore = b.includes('report') ? 0 : 1;
|
|
746
|
+
if (aScore !== bScore) {
|
|
747
|
+
return aScore - bScore;
|
|
748
|
+
}
|
|
749
|
+
return a.localeCompare(b);
|
|
750
|
+
});
|
|
751
|
+
for (const fileName of candidates) {
|
|
752
|
+
const fullPath = path.join(outputDir, fileName);
|
|
753
|
+
if (await isLikelyPlaywrightReport(fullPath)) {
|
|
754
|
+
return fullPath;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
async function isLikelyPlaywrightReport(filePath) {
|
|
760
|
+
try {
|
|
761
|
+
const raw = await fs_1.promises.readFile(filePath, 'utf8');
|
|
762
|
+
const parsed = JSON.parse(raw);
|
|
763
|
+
return (!!parsed && typeof parsed === 'object' && Array.isArray(parsed.suites));
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async function overwriteReportTargets(sourcePath, targets) {
|
|
770
|
+
const uniqueTargets = Array.from(new Set(targets)).filter((target) => target && target !== sourcePath);
|
|
771
|
+
await Promise.all(uniqueTargets.map(async (targetPath) => {
|
|
772
|
+
try {
|
|
773
|
+
await ensureDirectory(path.dirname(targetPath));
|
|
774
|
+
await fs_1.promises.copyFile(sourcePath, targetPath);
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
Logger_1.appLogger.warn(`Failed to copy merged Playwright report to ${targetPath}.`, error);
|
|
778
|
+
}
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Donobu always wants Playwright's JSON reporter enabled so we can build
|
|
783
|
+
* treatment plans. If the user did not explicitly configure it we add the
|
|
784
|
+
* environment defaults for location and filename.
|
|
785
|
+
*/
|
|
786
|
+
function applyJsonReportEnv(env, outputDir) {
|
|
787
|
+
if (!env.PLAYWRIGHT_JSON_OUTPUT_DIR) {
|
|
788
|
+
env.PLAYWRIGHT_JSON_OUTPUT_DIR = outputDir;
|
|
789
|
+
}
|
|
790
|
+
if (!env.PLAYWRIGHT_JSON_OUTPUT_NAME) {
|
|
791
|
+
env.PLAYWRIGHT_JSON_OUTPUT_NAME = PLAYWRIGHT_JSON_REPORT_FILENAME;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Inspect generated treatment plans to determine whether an automated rerun is
|
|
796
|
+
* viable. A plan qualifies if it explicitly opted into automation directives.
|
|
797
|
+
*/
|
|
798
|
+
function evaluateAutoHealEligibility(plans) {
|
|
799
|
+
const eligiblePlans = plans.filter((record) => {
|
|
800
|
+
const directives = record.plan.automationDirectives;
|
|
801
|
+
return (record.plan.shouldRetryAutomation === true &&
|
|
802
|
+
directives !== undefined &&
|
|
803
|
+
Object.keys(directives).length > 0);
|
|
804
|
+
});
|
|
805
|
+
const clearPageAiCache = eligiblePlans.some((record) => record.plan.automationDirectives?.clearPageAiCache === true);
|
|
806
|
+
const directives = derivePlaywrightDirectiveArgs(eligiblePlans.map((record) => ({
|
|
807
|
+
plan: record.plan,
|
|
808
|
+
testCase: record.evidence.failureContext.testCase,
|
|
809
|
+
})));
|
|
810
|
+
return {
|
|
811
|
+
eligiblePlans,
|
|
812
|
+
clearPageAiCache,
|
|
813
|
+
directives,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Coalesce directives from one or more treatment plans into a single Playwright
|
|
818
|
+
* invocation. Multiple failed tests can be healed in a single rerun, so we
|
|
819
|
+
* gather all relevant files/projects/titles here.
|
|
820
|
+
*/
|
|
821
|
+
function derivePlaywrightDirectiveArgs(descriptors) {
|
|
822
|
+
const targetFiles = new Set();
|
|
823
|
+
const targetProjects = new Set();
|
|
824
|
+
const targetTitles = new Set();
|
|
825
|
+
const additionalArgs = [];
|
|
826
|
+
for (const descriptor of descriptors) {
|
|
827
|
+
const directives = descriptor.plan.automationDirectives;
|
|
828
|
+
if (!directives) {
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
const fileCandidate = normalizeSpecPath(directives.targetTestFile ?? descriptor.testCase.file);
|
|
832
|
+
if (fileCandidate) {
|
|
833
|
+
targetFiles.add(fileCandidate);
|
|
834
|
+
}
|
|
835
|
+
const projectCandidate = directives.targetProject ?? descriptor.testCase.projectName;
|
|
836
|
+
if (projectCandidate && !looksLikePath(projectCandidate)) {
|
|
837
|
+
targetProjects.add(projectCandidate);
|
|
838
|
+
}
|
|
839
|
+
if (descriptor.testCase.title) {
|
|
840
|
+
targetTitles.add(descriptor.testCase.title);
|
|
841
|
+
}
|
|
842
|
+
if (directives.additionalPlaywrightArgs) {
|
|
843
|
+
directives.additionalPlaywrightArgs.forEach((arg) => {
|
|
844
|
+
additionalArgs.push(arg);
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
files: Array.from(targetFiles),
|
|
850
|
+
projects: Array.from(targetProjects),
|
|
851
|
+
grepPattern: targetTitles.size > 0
|
|
852
|
+
? Array.from(targetTitles)
|
|
853
|
+
.map((title) => escapeRegex(title))
|
|
854
|
+
.join('|')
|
|
855
|
+
: undefined,
|
|
856
|
+
extras: additionalArgs,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
// We match test titles via `--grep`, so ensure literal characters are escaped.
|
|
860
|
+
function escapeRegex(value) {
|
|
861
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
862
|
+
}
|
|
863
|
+
// Some teams name projects after directories (e.g. `projects/mobile`); treat those as file paths.
|
|
864
|
+
function looksLikePath(value) {
|
|
865
|
+
return value.includes('/') || value.includes('\\');
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Convert Playwright's test file references into something relative to the
|
|
869
|
+
* current workspace when possible. Relative paths make persisted plans easier
|
|
870
|
+
* to share between developers on different machines.
|
|
871
|
+
*/
|
|
872
|
+
function normalizeSpecPath(specPath) {
|
|
873
|
+
if (!specPath) {
|
|
874
|
+
return specPath;
|
|
875
|
+
}
|
|
876
|
+
const absolute = path.isAbsolute(specPath)
|
|
877
|
+
? specPath
|
|
878
|
+
: path.resolve(process.cwd(), specPath);
|
|
879
|
+
const relative = path.relative(process.cwd(), absolute);
|
|
880
|
+
return relative.startsWith('..') ? absolute : relative || specPath;
|
|
881
|
+
}
|
|
882
|
+
// The original CLI may have explicit spec files; keep them when no directive overrides exist.
|
|
883
|
+
function extractOriginalFiles(args) {
|
|
884
|
+
return args.slice(1).filter((arg) => !arg.startsWith('--') && arg !== 'test');
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Preserve most user-provided Playwright flags (e.g. `--config`, `--workers`).
|
|
888
|
+
* We only strip flags we know we're going to replace (projects, grep, reporter).
|
|
889
|
+
*/
|
|
890
|
+
function extractPreservedOptions(args) {
|
|
891
|
+
return args.slice(1).filter((arg) => {
|
|
892
|
+
if (!arg.startsWith('--')) {
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
const optionName = arg.startsWith('--') ? arg.split('=')[0] : arg;
|
|
896
|
+
return (optionName !== '--project' &&
|
|
897
|
+
!optionName.startsWith('--project=') &&
|
|
898
|
+
optionName !== '--grep' &&
|
|
899
|
+
optionName !== '--reporter' &&
|
|
900
|
+
!optionName.startsWith('--grep=') &&
|
|
901
|
+
!optionName.startsWith('--reporter='));
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Merge the user's original Playwright command with the automation directives
|
|
906
|
+
* suggested by treatment plans. Directives win over the original arguments but
|
|
907
|
+
* we keep the rest of the flags intact.
|
|
908
|
+
*/
|
|
909
|
+
function buildPlaywrightArgsWithDirectives(originalArgs, directives) {
|
|
910
|
+
const files = directives.files.length > 0
|
|
911
|
+
? directives.files
|
|
912
|
+
: extractOriginalFiles(originalArgs);
|
|
913
|
+
const preservedOptions = extractPreservedOptions(originalArgs);
|
|
914
|
+
const projectArgs = directives.projects.map((project) => `--project=${project}`);
|
|
915
|
+
const grepArgs = directives.grepPattern
|
|
916
|
+
? ['--grep', directives.grepPattern]
|
|
917
|
+
: [];
|
|
918
|
+
const finalArgs = [
|
|
919
|
+
'test',
|
|
920
|
+
...files,
|
|
921
|
+
...projectArgs,
|
|
922
|
+
...grepArgs,
|
|
923
|
+
...directives.extras,
|
|
924
|
+
...preservedOptions,
|
|
925
|
+
];
|
|
926
|
+
return finalArgs;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Force Playwright to emit artifacts into a specific directory, replacing any
|
|
930
|
+
* existing `--output` flag in the argv. Used by auto-heal so the rerun does not
|
|
931
|
+
* overwrite the original failure artifacts.
|
|
932
|
+
*/
|
|
933
|
+
function overrideOutputDir(args, outputDir) {
|
|
934
|
+
const rewritten = [];
|
|
935
|
+
let outputInjected = false;
|
|
936
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
937
|
+
const arg = args[i];
|
|
938
|
+
if (arg === '--output' || arg === '-o') {
|
|
939
|
+
outputInjected = true;
|
|
940
|
+
rewritten.push('--output', outputDir);
|
|
941
|
+
i += 1; // Skip the value that followed the original flag.
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
if (arg.startsWith('--output=')) {
|
|
945
|
+
outputInjected = true;
|
|
946
|
+
rewritten.push(`--output=${outputDir}`);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
rewritten.push(arg);
|
|
950
|
+
}
|
|
951
|
+
if (!outputInjected) {
|
|
952
|
+
rewritten.push('--output', outputDir);
|
|
953
|
+
}
|
|
954
|
+
return rewritten;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Treatment plans are persisted to disk between runs so engineers can apply
|
|
958
|
+
* them later. This schema guards against schema drift when the file is
|
|
959
|
+
* reloaded by the `heal` command.
|
|
960
|
+
*/
|
|
961
|
+
const PersistedTreatmentPlanFileSchema = v4_1.z.object({
|
|
962
|
+
generatedAtIso: v4_1.z.string(),
|
|
963
|
+
plan: triageTestFailure_1.TreatmentPlan,
|
|
964
|
+
failure: v4_1.z.object({
|
|
965
|
+
testCase: v4_1.z.object({
|
|
966
|
+
title: v4_1.z.string(),
|
|
967
|
+
file: v4_1.z.string().optional(),
|
|
968
|
+
projectName: v4_1.z.string().optional(),
|
|
969
|
+
}),
|
|
970
|
+
runId: v4_1.z.string().nullable().optional(),
|
|
971
|
+
runDirectory: v4_1.z.string().optional(),
|
|
972
|
+
evidencePath: v4_1.z.string(),
|
|
973
|
+
}),
|
|
974
|
+
originalPlaywrightArgs: v4_1.z.array(v4_1.z.string()).default([]),
|
|
975
|
+
reportPath: v4_1.z.string().optional(),
|
|
976
|
+
});
|
|
977
|
+
/**
|
|
978
|
+
* Read the evidence that Playwright (and Donobu's test fixtures) produced for a
|
|
979
|
+
* failing test. Evidence includes the Playwright context plus Donobu metadata.
|
|
980
|
+
*/
|
|
981
|
+
async function loadFailureEvidence(filePath) {
|
|
982
|
+
try {
|
|
983
|
+
const raw = await fs_1.promises.readFile(filePath, 'utf8');
|
|
984
|
+
return JSON.parse(raw);
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
Logger_1.appLogger.error(`Failed to read test-failure evidence at ${filePath}`, error);
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* After Playwright exits we iterate over any captured failure evidence,
|
|
993
|
+
* generate treatment plans (via GPT when available, otherwise heuristics), and
|
|
994
|
+
* persist the results next to the evidence files.
|
|
995
|
+
*
|
|
996
|
+
* This runs for the initial test pass and for later heal attempts.
|
|
997
|
+
*/
|
|
998
|
+
async function postProcessTriageRun(context, originalPlaywrightArgs, reportPath) {
|
|
999
|
+
const generatedPlans = [];
|
|
1000
|
+
const originalArgsSnapshot = [...originalPlaywrightArgs];
|
|
1001
|
+
let entries;
|
|
1002
|
+
try {
|
|
1003
|
+
entries = await fs_1.promises.readdir(context.runDir);
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
Logger_1.appLogger.error(`Unable to read test-failure triage directory ${context.runDir}.`, error);
|
|
1007
|
+
return generatedPlans;
|
|
1008
|
+
}
|
|
1009
|
+
const evidenceFiles = entries
|
|
1010
|
+
.filter((entry) => entry.startsWith(FAILURE_EVIDENCE_PREFIX) && entry.endsWith('.json'))
|
|
1011
|
+
.sort();
|
|
1012
|
+
if (evidenceFiles.length === 0) {
|
|
1013
|
+
Logger_1.appLogger.info(`No failure evidence found in ${context.runDir}.`);
|
|
1014
|
+
return generatedPlans;
|
|
1015
|
+
}
|
|
1016
|
+
// Lazily instantiate GPT client because local development and CI might not
|
|
1017
|
+
// have credentials available; if that fails we still ship heuristic plans.
|
|
1018
|
+
let gptClient = null;
|
|
1019
|
+
try {
|
|
1020
|
+
gptClient = await (0, gptClients_1.getOrCreateDefaultGptClient)();
|
|
1021
|
+
}
|
|
1022
|
+
catch (error) {
|
|
1023
|
+
Logger_1.appLogger.warn('Unable to instantiate GPT client for creating a treatment plan; falling back to heuristic treatment plans.', error);
|
|
1024
|
+
}
|
|
1025
|
+
for (const fileName of evidenceFiles) {
|
|
1026
|
+
const evidencePath = path.join(context.runDir, fileName);
|
|
1027
|
+
const evidence = await loadFailureEvidence(evidencePath);
|
|
1028
|
+
if (!evidence) {
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
const testLabel = evidence.failureContext.testCase.title ??
|
|
1032
|
+
evidence.failureContext.testCase.file ??
|
|
1033
|
+
'unknown test';
|
|
1034
|
+
Logger_1.appLogger.info(`Detected test failure for "${testLabel}". Generating treatment plan to facilitate healing...`);
|
|
1035
|
+
let plan = (0, triageTestFailure_1.buildTreatmentPlanFromHeuristics)(evidence);
|
|
1036
|
+
if (gptClient) {
|
|
1037
|
+
try {
|
|
1038
|
+
plan = await (0, triageTestFailure_1.generateTreatmentPlanFromEvidence)(gptClient, evidence);
|
|
1039
|
+
}
|
|
1040
|
+
catch (error) {
|
|
1041
|
+
Logger_1.appLogger.error(`Treatment plan generation failed for ${fileName}; using heuristic fallback.`, error);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (plan.automationDirectives &&
|
|
1045
|
+
Object.keys(plan.automationDirectives).length > 0) {
|
|
1046
|
+
const enrichedDirectives = { ...plan.automationDirectives };
|
|
1047
|
+
const targetFile = normalizeSpecPath(evidence.failureContext.testCase.file);
|
|
1048
|
+
const targetProject = evidence.failureContext.testCase.projectName;
|
|
1049
|
+
if (targetFile && !enrichedDirectives.targetTestFile) {
|
|
1050
|
+
enrichedDirectives.targetTestFile = targetFile;
|
|
1051
|
+
}
|
|
1052
|
+
if (targetProject && !enrichedDirectives.targetProject) {
|
|
1053
|
+
enrichedDirectives.targetProject = targetProject;
|
|
1054
|
+
}
|
|
1055
|
+
plan = {
|
|
1056
|
+
...plan,
|
|
1057
|
+
automationDirectives: enrichedDirectives,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
const planFileName = fileName.replace(FAILURE_EVIDENCE_PREFIX, TREATMENT_PLAN_PREFIX);
|
|
1061
|
+
const planPath = path.join(context.runDir, planFileName);
|
|
1062
|
+
const persisted = {
|
|
1063
|
+
generatedAtIso: new Date().toISOString(),
|
|
1064
|
+
plan,
|
|
1065
|
+
failure: {
|
|
1066
|
+
testCase: evidence.failureContext.testCase,
|
|
1067
|
+
runId: evidence.runId,
|
|
1068
|
+
runDirectory: evidence.runDirectory,
|
|
1069
|
+
evidencePath,
|
|
1070
|
+
},
|
|
1071
|
+
originalPlaywrightArgs: originalArgsSnapshot,
|
|
1072
|
+
reportPath,
|
|
1073
|
+
};
|
|
1074
|
+
await fs_1.promises.writeFile(planPath, JSON.stringify(persisted, null, 2), 'utf8');
|
|
1075
|
+
Logger_1.appLogger.info(`Saved test failure treatment plan for "${testLabel}" to "${planPath}"`);
|
|
1076
|
+
generatedPlans.push({
|
|
1077
|
+
plan,
|
|
1078
|
+
planPath,
|
|
1079
|
+
evidence,
|
|
1080
|
+
evidencePath,
|
|
1081
|
+
generatedAtIso: persisted.generatedAtIso,
|
|
1082
|
+
originalPlaywrightArgs: [...originalArgsSnapshot],
|
|
1083
|
+
reportPath,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
return generatedPlans;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Optionally launch a second Playwright run using the automation directives
|
|
1090
|
+
* produced by one or more treatment plans. This is the "auto-heal" feature
|
|
1091
|
+
* where Donobu tries to repair failures on its own. The rerun happens in a
|
|
1092
|
+
* temporary staging area so the user-visible Playwright output stays clean.
|
|
1093
|
+
*/
|
|
1094
|
+
async function attemptAutoHealRun(params) {
|
|
1095
|
+
const evaluation = evaluateAutoHealEligibility(params.generatedPlans);
|
|
1096
|
+
Logger_1.appLogger.info(`Auto-heal directives resolved to: ${JSON.stringify(evaluation.directives)}`);
|
|
1097
|
+
if (evaluation.eligiblePlans.length === 0) {
|
|
1098
|
+
Logger_1.appLogger.info('Auto-heal requested but no treatment plan provided actionable directives; skipping rerun.');
|
|
1099
|
+
return { attempted: false, exitCode: params.currentExitCode };
|
|
1100
|
+
}
|
|
1101
|
+
const staging = await createAutoHealStagingArea();
|
|
1102
|
+
let healExitCode = params.currentExitCode;
|
|
1103
|
+
const healOptions = {
|
|
1104
|
+
...params.options,
|
|
1105
|
+
clearAiCache: params.options.clearAiCache || evaluation.clearPageAiCache === true,
|
|
1106
|
+
autoHeal: false,
|
|
1107
|
+
triageOutputDir: staging.triageBaseDir,
|
|
1108
|
+
};
|
|
1109
|
+
if (evaluation.clearPageAiCache && !params.options.clearAiCache) {
|
|
1110
|
+
Logger_1.appLogger.info('Auto-heal: clearing Page.AI cache as recommended by the treatment plan.');
|
|
1111
|
+
}
|
|
1112
|
+
const healArgsWithDirectives = buildPlaywrightArgsWithDirectives(params.playwrightArgs, evaluation.directives);
|
|
1113
|
+
const healArgsForRun = overrideOutputDir(healArgsWithDirectives, staging.playwrightOutputDir);
|
|
1114
|
+
let healTriageContext = null;
|
|
1115
|
+
let healTriageEnabled = healOptions.triageEnabled;
|
|
1116
|
+
try {
|
|
1117
|
+
if (healTriageEnabled) {
|
|
1118
|
+
try {
|
|
1119
|
+
healTriageContext = await prepareTriageContext(staging.playwrightOutputDir, healOptions);
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
Logger_1.appLogger.error('Auto-heal: failed to prepare triage artifacts for the rerun. Continuing without triage.', error);
|
|
1123
|
+
healTriageEnabled = false;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
const envOverrides = createRunEnvOverrides({
|
|
1127
|
+
clearAiCache: healOptions.clearAiCache,
|
|
1128
|
+
triageEnabled: healTriageEnabled,
|
|
1129
|
+
triageContext: healTriageContext,
|
|
1130
|
+
});
|
|
1131
|
+
applyJsonReportEnv(envOverrides, staging.playwrightOutputDir);
|
|
1132
|
+
// Flag downstream systems so they know this invocation came from auto-heal.
|
|
1133
|
+
envOverrides.DONOBU_AUTO_HEAL_ACTIVE = '1';
|
|
1134
|
+
Logger_1.appLogger.info(`Auto-heal: applying directives from ${evaluation.eligiblePlans.length} treatment plan(s) and re-running Playwright...`);
|
|
1135
|
+
const healJsonReportPath = path.join(staging.playwrightOutputDir, PLAYWRIGHT_JSON_REPORT_FILENAME);
|
|
1136
|
+
const reporterSetup = await ensureJsonReporter(healArgsForRun, {
|
|
1137
|
+
jsonOutputFile: healJsonReportPath,
|
|
1138
|
+
});
|
|
1139
|
+
try {
|
|
1140
|
+
healExitCode = await runPlaywright(reporterSetup.args, envOverrides);
|
|
1141
|
+
}
|
|
1142
|
+
finally {
|
|
1143
|
+
await reporterSetup.cleanup();
|
|
1144
|
+
}
|
|
1145
|
+
const healReportDestination = path.join(params.playwrightOutputDir, `donobu-auto-heal-report-${Date.now()}.json`);
|
|
1146
|
+
const healReportCopy = await copyJsonReport(staging.playwrightOutputDir, healReportDestination, {
|
|
1147
|
+
envOverrides,
|
|
1148
|
+
additionalCandidates: [healJsonReportPath],
|
|
1149
|
+
});
|
|
1150
|
+
if (healTriageEnabled && healTriageContext && healExitCode !== 0) {
|
|
1151
|
+
await postProcessTriageRun(healTriageContext, healArgsForRun, healReportCopy?.destinationPath ?? undefined);
|
|
1152
|
+
const finalTriageBaseDir = params.options.triageOutputDir
|
|
1153
|
+
? path.resolve(params.options.triageOutputDir)
|
|
1154
|
+
: path.join(params.playwrightOutputDir, 'donobu-triage');
|
|
1155
|
+
const finalHealRunDir = path.join(finalTriageBaseDir, path.basename(healTriageContext.runDir));
|
|
1156
|
+
try {
|
|
1157
|
+
await ensureDirectory(path.dirname(finalHealRunDir));
|
|
1158
|
+
await fs_1.promises.cp(healTriageContext.runDir, finalHealRunDir, {
|
|
1159
|
+
recursive: true,
|
|
1160
|
+
errorOnExist: false,
|
|
1161
|
+
});
|
|
1162
|
+
Logger_1.appLogger.info(`Auto-heal: copied triage artifacts to ${finalHealRunDir} for inspection.`);
|
|
1163
|
+
}
|
|
1164
|
+
catch (error) {
|
|
1165
|
+
Logger_1.appLogger.warn(`Auto-heal: failed to persist triage artifacts to ${finalHealRunDir}.`, error);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (healExitCode === 0) {
|
|
1169
|
+
Logger_1.appLogger.info('Auto-heal completed successfully.');
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
Logger_1.appLogger.warn(`Auto-heal attempt exited with code ${healExitCode}. Keeping failing status.`);
|
|
1173
|
+
}
|
|
1174
|
+
if (params.initialReportPath || healReportCopy) {
|
|
1175
|
+
const mergedReportPath = path.join(params.playwrightOutputDir, `donobu-merged-report-${Date.now()}.json`);
|
|
1176
|
+
await mergePlaywrightJsonReports({
|
|
1177
|
+
initialReportPath: params.initialReportPath,
|
|
1178
|
+
healReportPath: healReportCopy?.destinationPath ?? undefined,
|
|
1179
|
+
mergedReportPath,
|
|
1180
|
+
healedTests: evaluation.eligiblePlans.map((record) => ({
|
|
1181
|
+
plan: record.plan,
|
|
1182
|
+
testCase: record.evidence.failureContext.testCase,
|
|
1183
|
+
})),
|
|
1184
|
+
healSucceeded: healExitCode === 0,
|
|
1185
|
+
});
|
|
1186
|
+
if (params.reportTargets.length > 0) {
|
|
1187
|
+
await overwriteReportTargets(mergedReportPath, params.reportTargets);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
finally {
|
|
1192
|
+
await staging.dispose();
|
|
1193
|
+
}
|
|
1194
|
+
return { attempted: true, exitCode: healExitCode };
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Combine the JSON reports from the original failed run and any auto-heal rerun.
|
|
1198
|
+
* The merged output is useful for dashboards that expect a single report file.
|
|
1199
|
+
*/
|
|
1200
|
+
async function mergePlaywrightJsonReports(params) {
|
|
1201
|
+
const initialReport = await readJsonIfExists(params.initialReportPath);
|
|
1202
|
+
const healReport = await readJsonIfExists(params.healReportPath);
|
|
1203
|
+
if (!initialReport && !healReport) {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
// Clone the reports so we never mutate the on-disk originals.
|
|
1207
|
+
const combined = initialReport
|
|
1208
|
+
? JSON.parse(JSON.stringify(initialReport))
|
|
1209
|
+
: JSON.parse(JSON.stringify(healReport));
|
|
1210
|
+
const initialIndex = indexReport(initialReport);
|
|
1211
|
+
const combinedIndex = indexReport(combined);
|
|
1212
|
+
const healIndex = indexReport(healReport);
|
|
1213
|
+
const healedKeys = new Set();
|
|
1214
|
+
if (healReport) {
|
|
1215
|
+
const processedHealEntries = new Set();
|
|
1216
|
+
const processHealEntry = (healEntry) => {
|
|
1217
|
+
const key = buildTestKey(healEntry.suite.file, healEntry.test.projectName, healEntry.test.title);
|
|
1218
|
+
let combinedEntry = (healEntry.test.testId
|
|
1219
|
+
? combinedIndex.byId.get(healEntry.test.testId)
|
|
1220
|
+
: undefined) ??
|
|
1221
|
+
combinedIndex.byKey.get(key) ??
|
|
1222
|
+
null;
|
|
1223
|
+
if (!combinedEntry) {
|
|
1224
|
+
combinedEntry = insertTestIntoReport(combined, healEntry);
|
|
1225
|
+
if (healEntry.test.testId) {
|
|
1226
|
+
combinedIndex.byId.set(healEntry.test.testId, combinedEntry);
|
|
1227
|
+
}
|
|
1228
|
+
combinedIndex.byKey.set(key, combinedEntry);
|
|
1229
|
+
}
|
|
1230
|
+
const originalEntry = (healEntry.test.testId
|
|
1231
|
+
? initialIndex.byId.get(healEntry.test.testId)
|
|
1232
|
+
: undefined) ??
|
|
1233
|
+
initialIndex.byKey.get(key) ??
|
|
1234
|
+
null;
|
|
1235
|
+
const combinedTest = combinedEntry.test;
|
|
1236
|
+
if (healEntry.test.results?.length) {
|
|
1237
|
+
combinedTest.results = [
|
|
1238
|
+
...(combinedTest.results ?? []),
|
|
1239
|
+
...healEntry.test.results,
|
|
1240
|
+
];
|
|
1241
|
+
}
|
|
1242
|
+
if (healEntry.test.status !== undefined) {
|
|
1243
|
+
combinedTest.status = healEntry.test.status;
|
|
1244
|
+
}
|
|
1245
|
+
if (healEntry.test.outcome !== undefined) {
|
|
1246
|
+
combinedTest.outcome = healEntry.test.outcome;
|
|
1247
|
+
}
|
|
1248
|
+
const originalStatus = originalEntry
|
|
1249
|
+
? getFinalResultStatus(originalEntry.test)
|
|
1250
|
+
: undefined;
|
|
1251
|
+
const healStatus = getFinalResultStatus(healEntry.test);
|
|
1252
|
+
if (healStatus === 'passed' &&
|
|
1253
|
+
originalStatus &&
|
|
1254
|
+
originalStatus !== 'passed') {
|
|
1255
|
+
combinedTest.annotations = combinedTest.annotations ?? [];
|
|
1256
|
+
if (!combinedTest.annotations.some((annotation) => annotation.type === 'self-healed')) {
|
|
1257
|
+
combinedTest.annotations.push({
|
|
1258
|
+
type: 'self-healed',
|
|
1259
|
+
description: 'Automatically healed by Donobu auto-heal rerun after applying treatment plan.',
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
combinedTest.donobuStatus = 'healed';
|
|
1263
|
+
healedKeys.add(key);
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
const iterateEntries = (entries) => {
|
|
1267
|
+
for (const [, healEntry] of entries) {
|
|
1268
|
+
if (processedHealEntries.has(healEntry)) {
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
processedHealEntries.add(healEntry);
|
|
1272
|
+
processHealEntry(healEntry);
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
iterateEntries(healIndex.byId);
|
|
1276
|
+
iterateEntries(healIndex.byKey);
|
|
1277
|
+
}
|
|
1278
|
+
if (params.healSucceeded && healedKeys.size === 0) {
|
|
1279
|
+
params.healedTests.forEach((descriptor) => {
|
|
1280
|
+
const key = buildTestKey(normalizeSpecPath(descriptor.testCase.file), descriptor.testCase.projectName, descriptor.testCase.title);
|
|
1281
|
+
const entry = combinedIndex.byKey.get(key);
|
|
1282
|
+
if (entry) {
|
|
1283
|
+
entry.test.annotations = entry.test.annotations ?? [];
|
|
1284
|
+
if (!entry.test.annotations.some((annotation) => annotation.type === 'self-healed')) {
|
|
1285
|
+
entry.test.annotations.push({
|
|
1286
|
+
type: 'self-healed',
|
|
1287
|
+
description: 'Automatically healed by Donobu auto-heal rerun after applying treatment plan.',
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
entry.test.donobuStatus = 'healed';
|
|
1291
|
+
healedKeys.add(key);
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
combined.stats = computeReportStats(combined);
|
|
1296
|
+
combined.metadata = {
|
|
1297
|
+
...(combined.metadata ?? {}),
|
|
1298
|
+
donobuMergedReport: true,
|
|
1299
|
+
mergedAtIso: new Date().toISOString(),
|
|
1300
|
+
sources: {
|
|
1301
|
+
initial: params.initialReportPath ?? null,
|
|
1302
|
+
autoHeal: params.healReportPath ?? null,
|
|
1303
|
+
},
|
|
1304
|
+
donobuHealedTests: Array.from(healedKeys.values()),
|
|
1305
|
+
};
|
|
1306
|
+
await ensureDirectory(path.dirname(params.mergedReportPath));
|
|
1307
|
+
await fs_1.promises.writeFile(params.mergedReportPath, JSON.stringify(combined, null, 2), 'utf8');
|
|
1308
|
+
Logger_1.appLogger.debug(`Saved merged Playwright report to ${params.mergedReportPath}.`);
|
|
1309
|
+
}
|
|
1310
|
+
// Playwright does not reliably expose stable IDs across reports; fall back to a composite key.
|
|
1311
|
+
function buildTestKey(file, projectName, title) {
|
|
1312
|
+
return [file ?? 'unknown-file', projectName ?? 'default', title ?? '']
|
|
1313
|
+
.map((segment) => segment.toString())
|
|
1314
|
+
.join('::');
|
|
1315
|
+
}
|
|
1316
|
+
function getFinalResultStatus(test) {
|
|
1317
|
+
if (!test) {
|
|
1318
|
+
return undefined;
|
|
1319
|
+
}
|
|
1320
|
+
return test.results?.at?.(-1)?.status ?? test.status;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Build lookup tables for quickly finding test entries inside a Playwright
|
|
1324
|
+
* report. We index by both `testId` (preferred) and the composite key to handle
|
|
1325
|
+
* differences between initial runs and reruns.
|
|
1326
|
+
*/
|
|
1327
|
+
function indexReport(report) {
|
|
1328
|
+
const byId = new Map();
|
|
1329
|
+
const byKey = new Map();
|
|
1330
|
+
if (!report?.suites) {
|
|
1331
|
+
return { byId, byKey };
|
|
1332
|
+
}
|
|
1333
|
+
report.suites.forEach((suite) => {
|
|
1334
|
+
suite.specs?.forEach((spec) => {
|
|
1335
|
+
spec.tests?.forEach((test) => {
|
|
1336
|
+
const entry = { suite, spec, test };
|
|
1337
|
+
if (test.testId) {
|
|
1338
|
+
byId.set(test.testId, entry);
|
|
1339
|
+
}
|
|
1340
|
+
const key = buildTestKey(suite.file, test.projectName, test.title);
|
|
1341
|
+
byKey.set(key, entry);
|
|
1342
|
+
});
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
return { byId, byKey };
|
|
1346
|
+
}
|
|
1347
|
+
function insertTestIntoReport(report, entry) {
|
|
1348
|
+
// Look for an existing suite/spec structure to attach the test clone to.
|
|
1349
|
+
let suite = report.suites?.find((candidate) => candidate.file === entry.suite.file);
|
|
1350
|
+
if (!suite) {
|
|
1351
|
+
suite = JSON.parse(JSON.stringify(entry.suite));
|
|
1352
|
+
suite.specs = [];
|
|
1353
|
+
report.suites = report.suites ?? [];
|
|
1354
|
+
report.suites.push(suite);
|
|
1355
|
+
}
|
|
1356
|
+
let spec = suite.specs.find((candidate) => candidate.title === entry.spec.title);
|
|
1357
|
+
if (!spec) {
|
|
1358
|
+
spec = JSON.parse(JSON.stringify(entry.spec));
|
|
1359
|
+
spec.tests = [];
|
|
1360
|
+
suite.specs.push(spec);
|
|
1361
|
+
}
|
|
1362
|
+
const testClone = JSON.parse(JSON.stringify(entry.test));
|
|
1363
|
+
spec.tests.push(testClone);
|
|
1364
|
+
return { suite, spec, test: testClone };
|
|
1365
|
+
}
|
|
1366
|
+
function computeReportStats(report) {
|
|
1367
|
+
let expected = 0;
|
|
1368
|
+
let unexpected = 0;
|
|
1369
|
+
let skipped = 0;
|
|
1370
|
+
let flaky = 0;
|
|
1371
|
+
let total = 0;
|
|
1372
|
+
let duration = 0;
|
|
1373
|
+
// Some reports come from external tools and might not include suites/tests.
|
|
1374
|
+
// Fall back to the existing stats block if we cannot recompute metrics.
|
|
1375
|
+
if (!report?.suites) {
|
|
1376
|
+
return report?.stats ?? {};
|
|
1377
|
+
}
|
|
1378
|
+
report.suites.forEach((suite) => {
|
|
1379
|
+
suite.specs?.forEach((spec) => {
|
|
1380
|
+
spec.tests?.forEach((test) => {
|
|
1381
|
+
total += 1;
|
|
1382
|
+
const finalResult = test.results?.at(-1);
|
|
1383
|
+
if (finalResult?.duration) {
|
|
1384
|
+
duration += finalResult.duration;
|
|
1385
|
+
}
|
|
1386
|
+
const status = finalResult?.status ?? test.status;
|
|
1387
|
+
switch (status) {
|
|
1388
|
+
case 'passed':
|
|
1389
|
+
expected += 1;
|
|
1390
|
+
break;
|
|
1391
|
+
case 'skipped':
|
|
1392
|
+
skipped += 1;
|
|
1393
|
+
break;
|
|
1394
|
+
case 'flaky':
|
|
1395
|
+
flaky += 1;
|
|
1396
|
+
break;
|
|
1397
|
+
case 'failed':
|
|
1398
|
+
case 'timedOut':
|
|
1399
|
+
case 'interrupted':
|
|
1400
|
+
unexpected += 1;
|
|
1401
|
+
break;
|
|
1402
|
+
default:
|
|
1403
|
+
unexpected += 1;
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
return {
|
|
1409
|
+
expected,
|
|
1410
|
+
unexpected,
|
|
1411
|
+
flaky,
|
|
1412
|
+
skipped,
|
|
1413
|
+
duration,
|
|
1414
|
+
total,
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
async function readJsonIfExists(filePath) {
|
|
1418
|
+
if (!filePath) {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
// Treat missing files as a normal condition; they simply mean the prior run
|
|
1422
|
+
// did not emit a JSON report. Other IO failures are logged as warnings.
|
|
1423
|
+
try {
|
|
1424
|
+
const raw = await fs_1.promises.readFile(filePath, 'utf8');
|
|
1425
|
+
return JSON.parse(raw);
|
|
1426
|
+
}
|
|
1427
|
+
catch (error) {
|
|
1428
|
+
if (error.code !== 'ENOENT') {
|
|
1429
|
+
Logger_1.appLogger.warn(`Failed to load Playwright JSON report at ${filePath}.`, error);
|
|
1430
|
+
}
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Parse arguments to `npx donobu heal` which replays a treatment plan outside
|
|
1436
|
+
* of the auto-heal flow. Everything after `--` is forwarded to Playwright.
|
|
1437
|
+
*/
|
|
1438
|
+
function parseHealArgs(args) {
|
|
1439
|
+
let planPath;
|
|
1440
|
+
const passthroughArgs = [];
|
|
1441
|
+
let triageOutputDir;
|
|
1442
|
+
let passthroughMode = false;
|
|
1443
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1444
|
+
const arg = args[i];
|
|
1445
|
+
if (passthroughMode) {
|
|
1446
|
+
passthroughArgs.push(arg);
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
if (arg === '--') {
|
|
1450
|
+
passthroughMode = true;
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
if (arg === '--plan') {
|
|
1454
|
+
planPath = args[i + 1];
|
|
1455
|
+
i += 1;
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
if (arg.startsWith('--plan=')) {
|
|
1459
|
+
planPath = arg.split('=', 2)[1];
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (arg === '--triage-output-dir') {
|
|
1463
|
+
triageOutputDir = args[i + 1];
|
|
1464
|
+
i += 1;
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
if (arg.startsWith('--triage-output-dir=')) {
|
|
1468
|
+
triageOutputDir = arg.split('=', 2)[1];
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
if (!planPath && !arg.startsWith('-')) {
|
|
1472
|
+
planPath = arg;
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
// Unrecognised token before passthrough marker becomes a forwarded arg.
|
|
1476
|
+
passthroughArgs.push(arg);
|
|
1477
|
+
}
|
|
1478
|
+
if (!planPath) {
|
|
1479
|
+
throw new Error('Missing required --plan <path> argument for heal command.');
|
|
1480
|
+
}
|
|
1481
|
+
return { planPath, passthroughArgs, triageOutputDir };
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Primary entrypoint for `npx donobu test`.
|
|
1485
|
+
* 1. Translates CLI flags into a Playwright invocation.
|
|
1486
|
+
* 2. Spawns Playwright and waits for completion.
|
|
1487
|
+
* 3. Collects failure evidence and generates treatment plans.
|
|
1488
|
+
* 4. Optionally runs an auto-heal retry if plans suggest it.
|
|
1489
|
+
*/
|
|
1490
|
+
async function runTestCommand(cliArgs) {
|
|
1491
|
+
const { options, playwrightArgs } = parseTestCommandArgs(cliArgs);
|
|
1492
|
+
const playwrightOutputDir = resolvePlaywrightOutputDir(playwrightArgs);
|
|
1493
|
+
if (options.clearAiCache) {
|
|
1494
|
+
Logger_1.appLogger.info('Running with Page.AI cache clearing enabled for this run.');
|
|
1495
|
+
}
|
|
1496
|
+
let triageEnabled = options.triageEnabled;
|
|
1497
|
+
let triageContext = null;
|
|
1498
|
+
if (triageEnabled) {
|
|
1499
|
+
try {
|
|
1500
|
+
triageContext = await prepareTriageContext(playwrightOutputDir, options);
|
|
1501
|
+
Logger_1.appLogger.debug(`[donobu triage] Will collect test failure evidence in ${triageContext.runDir}.`);
|
|
1502
|
+
}
|
|
1503
|
+
catch (error) {
|
|
1504
|
+
Logger_1.appLogger.error('Failed to prepare test-failure triage directory. Continuing without triage.', error);
|
|
1505
|
+
triageEnabled = false;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const effectiveOptions = {
|
|
1509
|
+
...options,
|
|
1510
|
+
triageEnabled,
|
|
1511
|
+
};
|
|
1512
|
+
const envOverrides = createRunEnvOverrides({
|
|
1513
|
+
clearAiCache: effectiveOptions.clearAiCache,
|
|
1514
|
+
triageEnabled: effectiveOptions.triageEnabled,
|
|
1515
|
+
triageContext,
|
|
1516
|
+
});
|
|
1517
|
+
applyJsonReportEnv(envOverrides, playwrightOutputDir);
|
|
1518
|
+
const reporterSetup = await ensureJsonReporter(playwrightArgs);
|
|
1519
|
+
const runArgs = reporterSetup.args;
|
|
1520
|
+
Logger_1.appLogger.debug(`Initial Playwright args: ${JSON.stringify(runArgs)} with env overrides ${JSON.stringify(envOverrides)}`);
|
|
1521
|
+
let exitCode;
|
|
1522
|
+
try {
|
|
1523
|
+
exitCode = await runPlaywright(runArgs, envOverrides);
|
|
1524
|
+
}
|
|
1525
|
+
finally {
|
|
1526
|
+
await reporterSetup.cleanup();
|
|
1527
|
+
}
|
|
1528
|
+
let generatedPlans = [];
|
|
1529
|
+
let initialReportCopy = null;
|
|
1530
|
+
const reportTargets = new Set();
|
|
1531
|
+
if (triageEnabled) {
|
|
1532
|
+
const initialReportDestination = triageContext
|
|
1533
|
+
? path.join(triageContext.runDir, 'initial-playwright-report.json')
|
|
1534
|
+
: path.join(playwrightOutputDir, `donobu-initial-report-${Date.now()}.json`);
|
|
1535
|
+
const initialReportResult = await copyJsonReport(playwrightOutputDir, initialReportDestination, {
|
|
1536
|
+
envOverrides,
|
|
1537
|
+
additionalCandidates: [
|
|
1538
|
+
path.join(playwrightOutputDir, PLAYWRIGHT_JSON_REPORT_FILENAME),
|
|
1539
|
+
],
|
|
1540
|
+
});
|
|
1541
|
+
if (initialReportResult) {
|
|
1542
|
+
initialReportCopy = initialReportResult.destinationPath;
|
|
1543
|
+
reportTargets.add(initialReportResult.sourcePath);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (reportTargets.size === 0) {
|
|
1547
|
+
const discoveredReport = await findJsonReportInDir(playwrightOutputDir);
|
|
1548
|
+
if (discoveredReport) {
|
|
1549
|
+
reportTargets.add(discoveredReport);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
if (triageEnabled && triageContext && exitCode !== 0) {
|
|
1553
|
+
generatedPlans = await postProcessTriageRun(triageContext, playwrightArgs, initialReportCopy ?? undefined);
|
|
1554
|
+
}
|
|
1555
|
+
if (exitCode === 0 || !effectiveOptions.autoHeal) {
|
|
1556
|
+
return exitCode;
|
|
1557
|
+
}
|
|
1558
|
+
const autoHealOutcome = await attemptAutoHealRun({
|
|
1559
|
+
options: effectiveOptions,
|
|
1560
|
+
playwrightArgs,
|
|
1561
|
+
playwrightOutputDir,
|
|
1562
|
+
generatedPlans,
|
|
1563
|
+
currentExitCode: exitCode,
|
|
1564
|
+
initialReportPath: initialReportCopy ?? undefined,
|
|
1565
|
+
reportTargets: Array.from(reportTargets),
|
|
1566
|
+
});
|
|
1567
|
+
return autoHealOutcome.exitCode;
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Apply a previously generated treatment plan manually. Engineers use this
|
|
1571
|
+
* entrypoint to re-run a specific plan locally or in CI without waiting for
|
|
1572
|
+
* auto-heal to trigger automatically.
|
|
1573
|
+
*/
|
|
1574
|
+
async function runHealCommand(cliArgs) {
|
|
1575
|
+
let parsed;
|
|
1576
|
+
try {
|
|
1577
|
+
parsed = parseHealArgs(cliArgs);
|
|
1578
|
+
}
|
|
1579
|
+
catch (error) {
|
|
1580
|
+
Logger_1.appLogger.error(error.message);
|
|
1581
|
+
return 1;
|
|
1582
|
+
}
|
|
1583
|
+
let persisted;
|
|
1584
|
+
try {
|
|
1585
|
+
const raw = await fs_1.promises.readFile(parsed.planPath, 'utf8');
|
|
1586
|
+
const json = JSON.parse(raw);
|
|
1587
|
+
persisted = PersistedTreatmentPlanFileSchema.parse(json);
|
|
1588
|
+
}
|
|
1589
|
+
catch (error) {
|
|
1590
|
+
Logger_1.appLogger.error(`Failed to read or parse treatment plan at ${parsed.planPath}.`, error);
|
|
1591
|
+
return 1;
|
|
1592
|
+
}
|
|
1593
|
+
const baseArgsForHeal = [
|
|
1594
|
+
...(persisted.originalPlaywrightArgs.length > 0
|
|
1595
|
+
? persisted.originalPlaywrightArgs
|
|
1596
|
+
: ['test']),
|
|
1597
|
+
...parsed.passthroughArgs,
|
|
1598
|
+
];
|
|
1599
|
+
const directives = derivePlaywrightDirectiveArgs([
|
|
1600
|
+
{
|
|
1601
|
+
plan: persisted.plan,
|
|
1602
|
+
testCase: persisted.failure.testCase,
|
|
1603
|
+
},
|
|
1604
|
+
]);
|
|
1605
|
+
const healArgsWithDirectives = buildPlaywrightArgsWithDirectives(baseArgsForHeal, directives);
|
|
1606
|
+
const playwrightOutputDir = resolvePlaywrightOutputDir(healArgsWithDirectives);
|
|
1607
|
+
const clearAiCache = persisted.plan.automationDirectives?.clearPageAiCache === true;
|
|
1608
|
+
if (!persisted.plan.shouldRetryAutomation) {
|
|
1609
|
+
Logger_1.appLogger.warn('Selected treatment plan does not recommend automated retries, proceeding anyway.');
|
|
1610
|
+
}
|
|
1611
|
+
const options = {
|
|
1612
|
+
triageEnabled: true,
|
|
1613
|
+
triageOutputDir: parsed.triageOutputDir,
|
|
1614
|
+
clearAiCache,
|
|
1615
|
+
autoHeal: false,
|
|
1616
|
+
};
|
|
1617
|
+
let triageEnabled = options.triageEnabled;
|
|
1618
|
+
let triageContext = null;
|
|
1619
|
+
if (triageEnabled) {
|
|
1620
|
+
try {
|
|
1621
|
+
triageContext = await prepareTriageContext(playwrightOutputDir, options);
|
|
1622
|
+
}
|
|
1623
|
+
catch (error) {
|
|
1624
|
+
Logger_1.appLogger.error('Failed to prepare test-failure triage directory for heal command. Continuing without triage.', error);
|
|
1625
|
+
triageEnabled = false;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const envOverrides = createRunEnvOverrides({
|
|
1629
|
+
clearAiCache,
|
|
1630
|
+
triageEnabled,
|
|
1631
|
+
triageContext,
|
|
1632
|
+
});
|
|
1633
|
+
applyJsonReportEnv(envOverrides, playwrightOutputDir);
|
|
1634
|
+
// Downstream hooks check this flag to avoid recursive auto-heal loops.
|
|
1635
|
+
envOverrides.DONOBU_AUTO_HEAL_ACTIVE = '1';
|
|
1636
|
+
Logger_1.appLogger.info(`Re-running Playwright using treatment plan at ${parsed.planPath}...`);
|
|
1637
|
+
const healJsonReportPath = path.join(playwrightOutputDir, PLAYWRIGHT_JSON_REPORT_FILENAME);
|
|
1638
|
+
const reporterSetup = await ensureJsonReporter(healArgsWithDirectives, {
|
|
1639
|
+
jsonOutputFile: healJsonReportPath,
|
|
1640
|
+
});
|
|
1641
|
+
Logger_1.appLogger.debug(`Heal command Playwright args: ${JSON.stringify(reporterSetup.args)} with env overrides ${JSON.stringify(envOverrides)}`);
|
|
1642
|
+
let exitCode;
|
|
1643
|
+
try {
|
|
1644
|
+
exitCode = await runPlaywright(reporterSetup.args, envOverrides);
|
|
1645
|
+
}
|
|
1646
|
+
finally {
|
|
1647
|
+
await reporterSetup.cleanup();
|
|
1648
|
+
}
|
|
1649
|
+
const healReportDestination = path.join(path.dirname(parsed.planPath), `donobu-heal-report-${Date.now()}.json`);
|
|
1650
|
+
const healReportCopy = await copyJsonReport(playwrightOutputDir, healReportDestination, {
|
|
1651
|
+
envOverrides,
|
|
1652
|
+
additionalCandidates: [healJsonReportPath],
|
|
1653
|
+
});
|
|
1654
|
+
if (triageEnabled && triageContext && exitCode !== 0) {
|
|
1655
|
+
await postProcessTriageRun(triageContext, healArgsWithDirectives, healReportCopy?.destinationPath ?? undefined);
|
|
1656
|
+
}
|
|
1657
|
+
if (persisted.reportPath || healReportCopy) {
|
|
1658
|
+
const mergedReportPath = path.join(path.dirname(parsed.planPath), 'donobu-heal-merged-report.json');
|
|
1659
|
+
await mergePlaywrightJsonReports({
|
|
1660
|
+
initialReportPath: persisted.reportPath,
|
|
1661
|
+
healReportPath: healReportCopy?.destinationPath ?? undefined,
|
|
1662
|
+
mergedReportPath,
|
|
1663
|
+
healedTests: [
|
|
1664
|
+
{
|
|
1665
|
+
plan: persisted.plan,
|
|
1666
|
+
testCase: persisted.failure.testCase,
|
|
1667
|
+
},
|
|
1668
|
+
],
|
|
1669
|
+
healSucceeded: exitCode === 0,
|
|
1670
|
+
});
|
|
1671
|
+
if (persisted.reportPath) {
|
|
1672
|
+
await overwriteReportTargets(mergedReportPath, [persisted.reportPath]);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return exitCode;
|
|
1676
|
+
}
|
|
1677
|
+
async function main() {
|
|
1678
|
+
// Support both `donobu test` (default) and `donobu heal` entrypoints.
|
|
1679
|
+
const cliArgs = process.argv.slice(2);
|
|
1680
|
+
if (cliArgs.length === 0 ||
|
|
1681
|
+
cliArgs[0] === 'test' ||
|
|
1682
|
+
cliArgs[0].startsWith('-')) {
|
|
1683
|
+
const exitCode = await runTestCommand(cliArgs);
|
|
1684
|
+
process.exit(exitCode);
|
|
1685
|
+
}
|
|
1686
|
+
if (cliArgs[0] === 'heal') {
|
|
1687
|
+
const exitCode = await runHealCommand(cliArgs.slice(1));
|
|
1688
|
+
process.exit(exitCode);
|
|
1689
|
+
}
|
|
1690
|
+
Logger_1.appLogger.error(`Unsupported command "${cliArgs[0]}". Expected "test" or "heal".`);
|
|
1691
|
+
process.exit(1);
|
|
1692
|
+
}
|
|
1693
|
+
// Top-level guard so node exits with a failure instead of leaving a rejected promise.
|
|
1694
|
+
main().catch((error) => {
|
|
1695
|
+
Logger_1.appLogger.error('Unexpected error in Donobu CLI:', error);
|
|
1696
|
+
process.exit(1);
|
|
1697
|
+
});
|
|
1698
|
+
//# sourceMappingURL=donobu-cli.js.map
|