donobu 3.9.0 → 3.11.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/cli/generate-site-tests.d.ts +3 -0
- package/dist/cli/generate-site-tests.d.ts.map +1 -0
- package/dist/cli/generate-site-tests.js +44 -0
- package/dist/cli/generate-site-tests.js.map +1 -0
- package/dist/codegen/CodeGenerator.d.ts +5 -1
- package/dist/codegen/CodeGenerator.d.ts.map +1 -1
- package/dist/codegen/CodeGenerator.js +37 -19
- package/dist/codegen/CodeGenerator.js.map +1 -1
- package/dist/codegen/runGenerateSiteTests.d.ts +70 -0
- package/dist/codegen/runGenerateSiteTests.d.ts.map +1 -0
- package/dist/codegen/runGenerateSiteTests.js +923 -0
- package/dist/codegen/runGenerateSiteTests.js.map +1 -0
- package/dist/esm/cli/generate-site-tests.d.ts +3 -0
- package/dist/esm/cli/generate-site-tests.d.ts.map +1 -0
- package/dist/esm/cli/generate-site-tests.js +44 -0
- package/dist/esm/cli/generate-site-tests.js.map +1 -0
- package/dist/esm/codegen/CodeGenerator.d.ts +5 -1
- package/dist/esm/codegen/CodeGenerator.d.ts.map +1 -1
- package/dist/esm/codegen/CodeGenerator.js +37 -19
- package/dist/esm/codegen/CodeGenerator.js.map +1 -1
- package/dist/esm/codegen/runGenerateSiteTests.d.ts +70 -0
- package/dist/esm/codegen/runGenerateSiteTests.d.ts.map +1 -0
- package/dist/esm/codegen/runGenerateSiteTests.js +923 -0
- package/dist/esm/codegen/runGenerateSiteTests.js.map +1 -0
- package/dist/esm/lib/DonobuExtendedPage.d.ts +181 -4
- package/dist/esm/lib/DonobuExtendedPage.d.ts.map +1 -1
- package/dist/esm/main.d.ts +1 -0
- package/dist/esm/main.d.ts.map +1 -1
- package/dist/esm/main.js +3 -1
- package/dist/esm/main.js.map +1 -1
- package/dist/esm/managers/DonobuFlowsManager.d.ts.map +1 -1
- package/dist/esm/managers/DonobuFlowsManager.js +14 -6
- package/dist/esm/managers/DonobuFlowsManager.js.map +1 -1
- package/dist/esm/models/CodeGenerationOptions.d.ts +4 -0
- package/dist/esm/models/CodeGenerationOptions.d.ts.map +1 -1
- package/dist/esm/models/CodeGenerationOptions.js +7 -0
- package/dist/esm/models/CodeGenerationOptions.js.map +1 -1
- package/dist/lib/DonobuExtendedPage.d.ts +181 -4
- package/dist/lib/DonobuExtendedPage.d.ts.map +1 -1
- package/dist/main.d.ts +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +3 -1
- package/dist/main.js.map +1 -1
- package/dist/managers/DonobuFlowsManager.d.ts.map +1 -1
- package/dist/managers/DonobuFlowsManager.js +14 -6
- package/dist/managers/DonobuFlowsManager.js.map +1 -1
- package/dist/models/CodeGenerationOptions.d.ts +4 -0
- package/dist/models/CodeGenerationOptions.d.ts.map +1 -1
- package/dist/models/CodeGenerationOptions.js +7 -0
- package/dist/models/CodeGenerationOptions.js.map +1 -1
- package/package.json +10 -9
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
#!/usr/bin/env ts-node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* Library entrypoint for Donobu Site Test Generator (coverage-aware).
|
|
5
|
+
*
|
|
6
|
+
* Use `runGenerateSiteTests(options, onProgress?, shouldCancel?)` to invoke programmatically.
|
|
7
|
+
*/
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.runGenerateSiteTests = runGenerateSiteTests;
|
|
13
|
+
const node_url_1 = require("node:url");
|
|
14
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const slugify_1 = __importDefault(require("slugify"));
|
|
17
|
+
const envVars_1 = require("../envVars");
|
|
18
|
+
const DonobuStack_1 = require("../managers/DonobuStack");
|
|
19
|
+
const BrowserStorageState_1 = require("../models/BrowserStorageState");
|
|
20
|
+
const ControlPanel_1 = require("../models/ControlPanel");
|
|
21
|
+
const EnvPersistenceVolatile_1 = require("../persistence/env/EnvPersistenceVolatile");
|
|
22
|
+
const AnalyzePageTextTool_1 = require("../tools/AnalyzePageTextTool");
|
|
23
|
+
const ChangeWebBrowserTabTool_1 = require("../tools/ChangeWebBrowserTabTool");
|
|
24
|
+
const ChooseSelectOptionTool_1 = require("../tools/ChooseSelectOptionTool");
|
|
25
|
+
const ClickTool_1 = require("../tools/ClickTool");
|
|
26
|
+
const CreateBrowserCookieReportTool_1 = require("../tools/CreateBrowserCookieReportTool");
|
|
27
|
+
const DetectBrokenLinksTool_1 = require("../tools/DetectBrokenLinksTool");
|
|
28
|
+
const GoForwardOrBackTool_1 = require("../tools/GoForwardOrBackTool");
|
|
29
|
+
const GoToWebpageTool_1 = require("../tools/GoToWebpageTool");
|
|
30
|
+
const HandleBrowserDialogTool_1 = require("../tools/HandleBrowserDialogTool");
|
|
31
|
+
const HoverOverElementTool_1 = require("../tools/HoverOverElementTool");
|
|
32
|
+
const InputRandomizedEmailAddressTool_1 = require("../tools/InputRandomizedEmailAddressTool");
|
|
33
|
+
const InputTextTool_1 = require("../tools/InputTextTool");
|
|
34
|
+
const MakeCommentTool_1 = require("../tools/MakeCommentTool");
|
|
35
|
+
const MarkObjectiveCompleteTool_1 = require("../tools/MarkObjectiveCompleteTool");
|
|
36
|
+
const MarkObjectiveNotCompletableTool_1 = require("../tools/MarkObjectiveNotCompletableTool");
|
|
37
|
+
const PressKeyTool_1 = require("../tools/PressKeyTool");
|
|
38
|
+
const RememberPageTextTool_1 = require("../tools/RememberPageTextTool");
|
|
39
|
+
const RunAccessibilityTestTool_1 = require("../tools/RunAccessibilityTestTool");
|
|
40
|
+
const ScrollPageTool_1 = require("../tools/ScrollPageTool");
|
|
41
|
+
const SummarizeLearningsTool_1 = require("../tools/SummarizeLearningsTool");
|
|
42
|
+
const WaitTool_1 = require("../tools/WaitTool");
|
|
43
|
+
const Logger_1 = require("../utils/Logger");
|
|
44
|
+
const CodeGenerator_1 = require("./CodeGenerator");
|
|
45
|
+
const DEFAULT_TEST_COUNT = 12;
|
|
46
|
+
const DEFAULT_DISCOVERY_STEPS = 32;
|
|
47
|
+
const DEFAULT_TEST_STEPS = 28;
|
|
48
|
+
function snapshotEnv() {
|
|
49
|
+
const envRecord = {};
|
|
50
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
51
|
+
if (value !== undefined) {
|
|
52
|
+
envRecord[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return envRecord;
|
|
56
|
+
}
|
|
57
|
+
async function loadStorageState(storageStatePath) {
|
|
58
|
+
if (!storageStatePath) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const raw = await promises_1.default.readFile(storageStatePath, 'utf8');
|
|
62
|
+
const parsed = JSON.parse(raw);
|
|
63
|
+
return BrowserStorageState_1.BrowserStorageStateSchema.parse(parsed);
|
|
64
|
+
}
|
|
65
|
+
async function ensureGptAvailability(donobuStack, gptConfigName) {
|
|
66
|
+
const { gptClient } = await donobuStack.flowsManager.createGptClient(gptConfigName ?? undefined);
|
|
67
|
+
if (!gptClient) {
|
|
68
|
+
throw new Error('No GPT client is configured. Provide --gpt-config-name or set OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function waitForFlow(flowHandle, label, options, onProgress) {
|
|
72
|
+
const pollIntervalMs = options?.pollIntervalMs ?? 500;
|
|
73
|
+
let cancelled = false;
|
|
74
|
+
const checkCancelled = async () => {
|
|
75
|
+
if (cancelled || !options?.shouldCancel?.()) {
|
|
76
|
+
if (cancelled) {
|
|
77
|
+
throw new Error('Cancelled');
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
cancelled = true;
|
|
82
|
+
onProgress?.({
|
|
83
|
+
type: 'log',
|
|
84
|
+
level: 'warn',
|
|
85
|
+
message: `Cancelling flow ${flowHandle.donobuFlow.metadata.id} (${label})`,
|
|
86
|
+
});
|
|
87
|
+
if (options?.onCancel) {
|
|
88
|
+
try {
|
|
89
|
+
await options.onCancel(flowHandle.donobuFlow.metadata.id);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
Logger_1.appLogger.warn(`Failed to cancel flow ${flowHandle.donobuFlow.metadata.id}`, error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error('Cancelled');
|
|
96
|
+
};
|
|
97
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
98
|
+
setTimeout(resolve, ms);
|
|
99
|
+
});
|
|
100
|
+
Logger_1.appLogger.info(`waiting for ${label} (${flowHandle.donobuFlow.metadata.id}) to finish...`);
|
|
101
|
+
onProgress?.({
|
|
102
|
+
type: 'flow',
|
|
103
|
+
stage: 'start',
|
|
104
|
+
flowId: flowHandle.donobuFlow.metadata.id,
|
|
105
|
+
name: flowHandle.donobuFlow.metadata.name ?? label,
|
|
106
|
+
});
|
|
107
|
+
let stopWatching = false;
|
|
108
|
+
const cancelWatcher = (async () => {
|
|
109
|
+
while (!stopWatching) {
|
|
110
|
+
await sleep(pollIntervalMs);
|
|
111
|
+
await checkCancelled();
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
try {
|
|
115
|
+
await checkCancelled();
|
|
116
|
+
await Promise.race([flowHandle.job, cancelWatcher]);
|
|
117
|
+
stopWatching = true;
|
|
118
|
+
await cancelWatcher.catch(() => { });
|
|
119
|
+
await checkCancelled();
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
stopWatching = true;
|
|
123
|
+
await cancelWatcher.catch(() => { });
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
const metadata = flowHandle.donobuFlow.metadata;
|
|
127
|
+
const allowedStates = options?.acceptStates ?? ['SUCCESS'];
|
|
128
|
+
if (!allowedStates.includes(metadata.state)) {
|
|
129
|
+
throw new Error(`Flow ${metadata.id} ended in state ${metadata.state}`);
|
|
130
|
+
}
|
|
131
|
+
onProgress?.({
|
|
132
|
+
type: 'flow',
|
|
133
|
+
stage: 'end',
|
|
134
|
+
flowId: flowHandle.donobuFlow.metadata.id,
|
|
135
|
+
name: metadata.name ?? label,
|
|
136
|
+
});
|
|
137
|
+
return metadata;
|
|
138
|
+
}
|
|
139
|
+
function buildAllowedTools(allowlist, denylist) {
|
|
140
|
+
const base = [
|
|
141
|
+
AnalyzePageTextTool_1.AnalyzePageTextTool.NAME,
|
|
142
|
+
ChangeWebBrowserTabTool_1.ChangeWebBrowserTabTool.NAME,
|
|
143
|
+
ChooseSelectOptionTool_1.ChooseSelectOptionTool.NAME,
|
|
144
|
+
ClickTool_1.ClickTool.NAME,
|
|
145
|
+
CreateBrowserCookieReportTool_1.CreateBrowserCookieReportTool.NAME,
|
|
146
|
+
DetectBrokenLinksTool_1.DetectBrokenLinksTool.NAME,
|
|
147
|
+
GoForwardOrBackTool_1.GoForwardOrBackTool.NAME,
|
|
148
|
+
GoToWebpageTool_1.GoToWebpageTool.NAME,
|
|
149
|
+
HandleBrowserDialogTool_1.HandleBrowserDialogTool.NAME,
|
|
150
|
+
HoverOverElementTool_1.HoverOverElementTool.NAME,
|
|
151
|
+
InputRandomizedEmailAddressTool_1.InputRandomizedEmailAddressTool.NAME,
|
|
152
|
+
InputTextTool_1.InputTextTool.NAME,
|
|
153
|
+
MakeCommentTool_1.MakeCommentTool.NAME,
|
|
154
|
+
MarkObjectiveCompleteTool_1.MarkObjectiveCompleteTool.NAME,
|
|
155
|
+
MarkObjectiveNotCompletableTool_1.MarkObjectiveNotCompletableTool.NAME,
|
|
156
|
+
PressKeyTool_1.PressKeyTool.NAME,
|
|
157
|
+
RememberPageTextTool_1.RememberPageTextTool.NAME,
|
|
158
|
+
RunAccessibilityTestTool_1.RunAccessibilityTestTool.NAME,
|
|
159
|
+
ScrollPageTool_1.ScrollPageTool.NAME,
|
|
160
|
+
SummarizeLearningsTool_1.SummarizeLearningsTool.NAME,
|
|
161
|
+
WaitTool_1.WaitTool.NAME,
|
|
162
|
+
];
|
|
163
|
+
let tools = [...base];
|
|
164
|
+
if (allowlist && allowlist.length > 0) {
|
|
165
|
+
const set = new Set(allowlist);
|
|
166
|
+
tools = tools.filter((t) => set.has(t));
|
|
167
|
+
}
|
|
168
|
+
if (denylist && denylist.length > 0) {
|
|
169
|
+
const deny = new Set(denylist);
|
|
170
|
+
tools = tools.filter((t) => !deny.has(t));
|
|
171
|
+
}
|
|
172
|
+
return Array.from(new Set(tools));
|
|
173
|
+
}
|
|
174
|
+
function buildBrowserConfig(storageState, headed, persona) {
|
|
175
|
+
const baseConfig = {
|
|
176
|
+
using: {
|
|
177
|
+
type: 'device',
|
|
178
|
+
headless: !headed,
|
|
179
|
+
},
|
|
180
|
+
persistState: false,
|
|
181
|
+
};
|
|
182
|
+
if (!storageState || persona === 'guest') {
|
|
183
|
+
return baseConfig;
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
...baseConfig,
|
|
187
|
+
initialState: {
|
|
188
|
+
type: 'json',
|
|
189
|
+
value: storageState,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function buildDiscoveryObjective(targetUrl, maxTests, hasAuthState) {
|
|
194
|
+
const authLine = hasAuthState
|
|
195
|
+
? 'Assume a valid signed-in storage state is preloaded; do not log out.'
|
|
196
|
+
: `If sign-in is required, keep exploring guest-accessible areas and mark tests that require auth.
|
|
197
|
+
If blocked by auth, use the ${MarkObjectiveNotCompletableTool_1.MarkObjectiveNotCompletableTool.NAME} tool with a short note.`;
|
|
198
|
+
return `Profile ${targetUrl} to understand the product surface (ecommerce, SaaS, social, content, admin, etc.).
|
|
199
|
+
Explore major journeys: onboarding/auth, primary conversion paths, search/browse, account/settings, support/help, and any admin areas.
|
|
200
|
+
${authLine}
|
|
201
|
+
|
|
202
|
+
While exploring, capture concise test ideas that can be run autonomously with Donobu tools. Prefer flows with clear assertions and business value.
|
|
203
|
+
Aim for ${maxTests} high-signal, non-duplicative tests that span different page types and risk areas.
|
|
204
|
+
Skip email-based verification loops and irreversible destructive actions.
|
|
205
|
+
|
|
206
|
+
When ready, populate the JSON result and call ${MarkObjectiveCompleteTool_1.MarkObjectiveCompleteTool.NAME}.`;
|
|
207
|
+
}
|
|
208
|
+
function buildDiscoverySchema() {
|
|
209
|
+
return {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
siteProfile: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
properties: {
|
|
215
|
+
siteType: { type: 'string' },
|
|
216
|
+
primaryAudiences: { type: 'array', items: { type: 'string' } },
|
|
217
|
+
requiresAuth: { type: 'boolean' },
|
|
218
|
+
authNotes: { type: 'string' },
|
|
219
|
+
keyJourneys: { type: 'array', items: { type: 'string' } },
|
|
220
|
+
riskAreas: { type: 'array', items: { type: 'string' } },
|
|
221
|
+
},
|
|
222
|
+
required: [
|
|
223
|
+
'siteType',
|
|
224
|
+
'primaryAudiences',
|
|
225
|
+
'requiresAuth',
|
|
226
|
+
'keyJourneys',
|
|
227
|
+
'riskAreas',
|
|
228
|
+
],
|
|
229
|
+
additionalProperties: false,
|
|
230
|
+
},
|
|
231
|
+
testCases: {
|
|
232
|
+
type: 'array',
|
|
233
|
+
items: {
|
|
234
|
+
type: 'object',
|
|
235
|
+
properties: {
|
|
236
|
+
title: { type: 'string' },
|
|
237
|
+
objective: { type: 'string' },
|
|
238
|
+
assertions: { type: 'array', items: { type: 'string' } },
|
|
239
|
+
category: { type: 'string' },
|
|
240
|
+
startUrl: { type: 'string' },
|
|
241
|
+
dependsOnAuth: { type: 'boolean' },
|
|
242
|
+
dataNeeds: { type: 'array', items: { type: 'string' } },
|
|
243
|
+
priority: { type: 'string', enum: ['P0', 'P1', 'P2'] },
|
|
244
|
+
},
|
|
245
|
+
required: ['title', 'objective', 'assertions'],
|
|
246
|
+
additionalProperties: false,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
required: ['siteProfile', 'testCases'],
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function buildTestObjective(testCase, persona, _siteProfile, coverageArea) {
|
|
255
|
+
const authLine = persona === 'auth'
|
|
256
|
+
? 'A signed-in storage state is already loaded. Stay signed in; do not log out.'
|
|
257
|
+
: `Stay in a guest session. If authentication blocks progress, use ${MarkObjectiveNotCompletableTool_1.MarkObjectiveNotCompletableTool.NAME} with a one-line reason.`;
|
|
258
|
+
const assertions = testCase.assertions && testCase.assertions.length > 0
|
|
259
|
+
? testCase.assertions.map((a) => `- ${a}`).join('\n')
|
|
260
|
+
: '- Record at least one clear assertion before finishing.';
|
|
261
|
+
const specialtyStep = coverageArea === 'accessibility'
|
|
262
|
+
? 'Notes: After reaching the main assertion point, run runAccessibilityTest and note any critical violations.'
|
|
263
|
+
: coverageArea === 'links'
|
|
264
|
+
? 'Notes: Run detectBrokenLinks (with screenshots if possible) and treat dead links as failures.'
|
|
265
|
+
: coverageArea === 'cookies'
|
|
266
|
+
? 'Notes: Run createBrowserCookieReport and flag high-risk cookies in the assertion.'
|
|
267
|
+
: '';
|
|
268
|
+
return `Goal: ${testCase.title}
|
|
269
|
+
Persona: ${authLine}
|
|
270
|
+
Objective: ${testCase.objective}
|
|
271
|
+
Assertions:
|
|
272
|
+
${assertions}
|
|
273
|
+
${specialtyStep ? `${specialtyStep}\n` : ''}Keep selectors stable, avoid redundant navigation, and finish by calling ${MarkObjectiveCompleteTool_1.MarkObjectiveCompleteTool.NAME} when assertions pass.`;
|
|
274
|
+
}
|
|
275
|
+
async function writeProject(projectDir, files) {
|
|
276
|
+
for (const file of files) {
|
|
277
|
+
const destination = path_1.default.join(projectDir, file.path);
|
|
278
|
+
await promises_1.default.mkdir(path_1.default.dirname(destination), { recursive: true });
|
|
279
|
+
await promises_1.default.writeFile(destination, file.content, 'utf8');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function writePlanArtifacts(projectDir, discovery, selected) {
|
|
283
|
+
const planDir = path_1.default.join(projectDir, 'plan');
|
|
284
|
+
await promises_1.default.mkdir(planDir, { recursive: true });
|
|
285
|
+
await promises_1.default.writeFile(path_1.default.join(planDir, 'discovery.json'), JSON.stringify(discovery, null, 2), 'utf8');
|
|
286
|
+
await promises_1.default.writeFile(path_1.default.join(planDir, 'plan.json'), JSON.stringify(selected, null, 2), 'utf8');
|
|
287
|
+
const mdLines = [
|
|
288
|
+
'# Proposed Tests',
|
|
289
|
+
'',
|
|
290
|
+
`Site type: ${discovery.siteProfile.siteType}`,
|
|
291
|
+
`Requires auth: ${discovery.siteProfile.requiresAuth}`,
|
|
292
|
+
'',
|
|
293
|
+
];
|
|
294
|
+
selected.forEach((tc, idx) => {
|
|
295
|
+
mdLines.push(`## ${idx + 1}. ${tc.title}`);
|
|
296
|
+
mdLines.push(tc.objective);
|
|
297
|
+
mdLines.push('');
|
|
298
|
+
mdLines.push(`- Persona: ${tc.persona}`);
|
|
299
|
+
mdLines.push(`- Coverage: ${tc.coverageArea}`);
|
|
300
|
+
mdLines.push(`- Source: ${tc.source}`);
|
|
301
|
+
if (tc.category) {
|
|
302
|
+
mdLines.push(`- Category: ${tc.category}`);
|
|
303
|
+
}
|
|
304
|
+
if (tc.priority) {
|
|
305
|
+
mdLines.push(`- Priority: ${tc.priority}`);
|
|
306
|
+
}
|
|
307
|
+
if (tc.assertions?.length) {
|
|
308
|
+
mdLines.push('Assertions:');
|
|
309
|
+
tc.assertions.forEach((a) => mdLines.push(`- ${a}`));
|
|
310
|
+
mdLines.push('');
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
await promises_1.default.writeFile(path_1.default.join(planDir, 'test-cases.md'), mdLines.join('\n'), 'utf8');
|
|
314
|
+
}
|
|
315
|
+
function matches(text, patterns) {
|
|
316
|
+
const lower = text.toLowerCase();
|
|
317
|
+
return patterns.some((p) => lower.includes(p));
|
|
318
|
+
}
|
|
319
|
+
function normalizeTitle(title) {
|
|
320
|
+
return title
|
|
321
|
+
.toLowerCase()
|
|
322
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
323
|
+
.replace(/^-+|-+$/g, '');
|
|
324
|
+
}
|
|
325
|
+
async function loadExistingPlan(projectDir) {
|
|
326
|
+
try {
|
|
327
|
+
const planPath = path_1.default.join(projectDir, 'plan', 'plan.json');
|
|
328
|
+
const data = await promises_1.default.readFile(planPath, 'utf8');
|
|
329
|
+
const parsed = JSON.parse(data);
|
|
330
|
+
return parsed.map((p) => ({
|
|
331
|
+
...p,
|
|
332
|
+
coverageArea: p.coverageArea ?? 'content',
|
|
333
|
+
persona: p.persona ?? 'guest',
|
|
334
|
+
source: p.source ?? 'discovery',
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
catch (_e) {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function pathExists(filepath) {
|
|
342
|
+
try {
|
|
343
|
+
await promises_1.default.access(filepath);
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async function findProjectDir(projectSlug, userOut) {
|
|
351
|
+
const planExists = async (dir) => pathExists(path_1.default.join(dir, 'plan', 'plan.json'));
|
|
352
|
+
if (userOut) {
|
|
353
|
+
const base = path_1.default.resolve(userOut);
|
|
354
|
+
if (await planExists(base)) {
|
|
355
|
+
return base;
|
|
356
|
+
}
|
|
357
|
+
const candidate = path_1.default.join(base, `${projectSlug}-donobu-tests`);
|
|
358
|
+
return candidate;
|
|
359
|
+
}
|
|
360
|
+
const cwdDir = path_1.default.resolve(process.cwd());
|
|
361
|
+
if (await planExists(cwdDir)) {
|
|
362
|
+
return cwdDir;
|
|
363
|
+
}
|
|
364
|
+
return path_1.default.resolve('./out', `${projectSlug}-donobu-tests`);
|
|
365
|
+
}
|
|
366
|
+
function buildCatchAllPlaywrightConfig(options) {
|
|
367
|
+
const { runInHeadedMode, slowMotionDelay, areElementIdsVolatile, disableSelectorFailover, playwrightScriptVariant, disableSelfHealingTests, } = options;
|
|
368
|
+
const useConfig = {
|
|
369
|
+
screenshot: 'on',
|
|
370
|
+
video: 'on',
|
|
371
|
+
...(runInHeadedMode && { headless: !runInHeadedMode }),
|
|
372
|
+
...(slowMotionDelay &&
|
|
373
|
+
slowMotionDelay > 0 && { launchOptions: { slowMo: slowMotionDelay } }),
|
|
374
|
+
};
|
|
375
|
+
const selfHealingOptions = {
|
|
376
|
+
areElementIdsVolatile,
|
|
377
|
+
disableSelectorFailover,
|
|
378
|
+
};
|
|
379
|
+
const metadata = !disableSelfHealingTests && playwrightScriptVariant === 'classic'
|
|
380
|
+
? `metadata: ${JSON.stringify({ selfHealingOptions: selfHealingOptions }, null, 2)}`
|
|
381
|
+
: '';
|
|
382
|
+
return `import { defineConfig, devices } from 'donobu';
|
|
383
|
+
|
|
384
|
+
export default defineConfig({
|
|
385
|
+
testDir: './tests',
|
|
386
|
+
projects: [
|
|
387
|
+
{
|
|
388
|
+
name: 'all-tests',
|
|
389
|
+
testMatch: '**/*.spec.ts',
|
|
390
|
+
use: { ...devices['Desktop Chromium'] },
|
|
391
|
+
timeout: 60000
|
|
392
|
+
}
|
|
393
|
+
],
|
|
394
|
+
use: ${JSON.stringify(useConfig, null, 2)},
|
|
395
|
+
reporter: [
|
|
396
|
+
["github"],
|
|
397
|
+
["json", { outputFile: "test-results/playwright-report.json" }],
|
|
398
|
+
["html", { outputFolder: "playwright-report", open: "never" }]
|
|
399
|
+
],
|
|
400
|
+
${metadata}
|
|
401
|
+
});`;
|
|
402
|
+
}
|
|
403
|
+
function pickPersona(testCase, hasAuthState, siteRequiresAuth) {
|
|
404
|
+
if (testCase.dependsOnAuth || siteRequiresAuth) {
|
|
405
|
+
return hasAuthState ? 'auth' : 'guest';
|
|
406
|
+
}
|
|
407
|
+
return 'guest';
|
|
408
|
+
}
|
|
409
|
+
function prioritize(testCases) {
|
|
410
|
+
const priorityRank = { P0: 0, P1: 1, P2: 2 };
|
|
411
|
+
return [...testCases].sort((a, b) => {
|
|
412
|
+
const pa = priorityRank[a.priority ?? 'P2'] ?? 3;
|
|
413
|
+
const pb = priorityRank[b.priority ?? 'P2'] ?? 3;
|
|
414
|
+
if (pa !== pb)
|
|
415
|
+
return pa - pb;
|
|
416
|
+
return (a.title || '').localeCompare(b.title || '');
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
function synthesizeTestCases(targetUrl, hasAuthState) {
|
|
420
|
+
return {
|
|
421
|
+
smoke: {
|
|
422
|
+
title: 'Homepage smoke and navigation',
|
|
423
|
+
objective: 'Open the homepage, verify the main hero/CTA is visible, and traverse primary navigation links without errors.',
|
|
424
|
+
assertions: [
|
|
425
|
+
'Hero heading and primary CTA are visible',
|
|
426
|
+
'Top navigation links route without errors',
|
|
427
|
+
],
|
|
428
|
+
startUrl: targetUrl,
|
|
429
|
+
priority: 'P0',
|
|
430
|
+
category: 'Smoke',
|
|
431
|
+
dependsOnAuth: false,
|
|
432
|
+
},
|
|
433
|
+
auth: {
|
|
434
|
+
title: 'Authenticated session is honored',
|
|
435
|
+
objective: 'Confirm the provided storage state keeps the user signed in and can reach a personalized area (dashboard/profile).',
|
|
436
|
+
assertions: [
|
|
437
|
+
'User avatar/name or account menu is visible',
|
|
438
|
+
'A personalized page loads without redirect to login',
|
|
439
|
+
],
|
|
440
|
+
startUrl: targetUrl,
|
|
441
|
+
priority: 'P0',
|
|
442
|
+
category: 'Authentication',
|
|
443
|
+
dependsOnAuth: hasAuthState,
|
|
444
|
+
},
|
|
445
|
+
navigation: {
|
|
446
|
+
title: 'Deep navigation path',
|
|
447
|
+
objective: 'Navigate through at least two levels of menus or breadcrumbs to a secondary page and verify page-specific content.',
|
|
448
|
+
assertions: [
|
|
449
|
+
'Second-level page loads without error',
|
|
450
|
+
'Breadcrumb or section header reflects the path',
|
|
451
|
+
],
|
|
452
|
+
startUrl: targetUrl,
|
|
453
|
+
priority: 'P1',
|
|
454
|
+
category: 'Navigation',
|
|
455
|
+
},
|
|
456
|
+
search: {
|
|
457
|
+
title: 'Search or filtering flow',
|
|
458
|
+
objective: 'Execute a search or apply filters for a meaningful term and validate result relevance and empty states.',
|
|
459
|
+
assertions: [
|
|
460
|
+
'Results update based on query/filter',
|
|
461
|
+
'Empty state message appears for a nonsense query',
|
|
462
|
+
],
|
|
463
|
+
startUrl: targetUrl,
|
|
464
|
+
priority: 'P1',
|
|
465
|
+
category: 'Search',
|
|
466
|
+
},
|
|
467
|
+
forms: {
|
|
468
|
+
title: 'Form submission with validation',
|
|
469
|
+
objective: 'Complete a representative form (contact/signup/checkout) including both valid and invalid data paths, observing validation messages.',
|
|
470
|
+
assertions: [
|
|
471
|
+
'Invalid input triggers inline validation',
|
|
472
|
+
'Valid submission yields success toast or confirmation',
|
|
473
|
+
],
|
|
474
|
+
startUrl: targetUrl,
|
|
475
|
+
priority: 'P1',
|
|
476
|
+
category: 'Forms',
|
|
477
|
+
},
|
|
478
|
+
checkout: {
|
|
479
|
+
title: 'Cart/checkout happy path',
|
|
480
|
+
objective: 'Add an item to cart (or equivalent action) and progress through checkout/confirmation until a stable success indicator.',
|
|
481
|
+
assertions: [
|
|
482
|
+
'Cart count increments',
|
|
483
|
+
'Order/confirmation or review page shows totals',
|
|
484
|
+
],
|
|
485
|
+
startUrl: targetUrl,
|
|
486
|
+
priority: 'P0',
|
|
487
|
+
category: 'Checkout',
|
|
488
|
+
},
|
|
489
|
+
content: {
|
|
490
|
+
title: 'Content/detail page integrity',
|
|
491
|
+
objective: 'Open a representative detail page (product/article/profile) and verify key metadata, media, and links render correctly.',
|
|
492
|
+
assertions: [
|
|
493
|
+
'Title and key metadata visible',
|
|
494
|
+
'Primary media loads (image/video)',
|
|
495
|
+
],
|
|
496
|
+
startUrl: targetUrl,
|
|
497
|
+
priority: 'P2',
|
|
498
|
+
category: 'Content',
|
|
499
|
+
},
|
|
500
|
+
accessibility: {
|
|
501
|
+
title: 'Accessibility scan on homepage',
|
|
502
|
+
objective: 'Run an axe-core accessibility scan on the homepage and summarize any violations.',
|
|
503
|
+
assertions: [
|
|
504
|
+
'Accessibility scan completes',
|
|
505
|
+
'No critical violations remain unresolved',
|
|
506
|
+
],
|
|
507
|
+
startUrl: targetUrl,
|
|
508
|
+
priority: 'P1',
|
|
509
|
+
category: 'Accessibility',
|
|
510
|
+
},
|
|
511
|
+
links: {
|
|
512
|
+
title: 'Dead links check on homepage',
|
|
513
|
+
objective: 'Detect broken links on the homepage and capture evidence for any dead or redirected links.',
|
|
514
|
+
assertions: ['No dead links on homepage', 'Redirect chains are healthy'],
|
|
515
|
+
startUrl: targetUrl,
|
|
516
|
+
priority: 'P1',
|
|
517
|
+
category: 'Links',
|
|
518
|
+
},
|
|
519
|
+
cookies: {
|
|
520
|
+
title: 'Cookie and storage audit',
|
|
521
|
+
objective: 'Collect a cookie/storage report and call out any high-risk cookies (insecure, cross-site, long-lived).',
|
|
522
|
+
assertions: [
|
|
523
|
+
'Report includes cookies and storage entries',
|
|
524
|
+
'High-risk cookies are identified',
|
|
525
|
+
],
|
|
526
|
+
startUrl: targetUrl,
|
|
527
|
+
priority: 'P2',
|
|
528
|
+
category: 'Cookies',
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function matchesCoverage(testCase, area) {
|
|
533
|
+
const text = `${testCase.title ?? ''} ${testCase.objective ?? ''}`.toLowerCase();
|
|
534
|
+
switch (area) {
|
|
535
|
+
case 'smoke':
|
|
536
|
+
return matches(text, [
|
|
537
|
+
'home',
|
|
538
|
+
'landing',
|
|
539
|
+
'nav',
|
|
540
|
+
'navigation',
|
|
541
|
+
'dashboard',
|
|
542
|
+
]);
|
|
543
|
+
case 'auth':
|
|
544
|
+
return matches(text, ['login', 'sign in', 'auth', 'session']);
|
|
545
|
+
case 'navigation':
|
|
546
|
+
return matches(text, ['navigate', 'navigation', 'menu', 'breadcrumb']);
|
|
547
|
+
case 'search':
|
|
548
|
+
return matches(text, ['search', 'filter', 'browse', 'query']);
|
|
549
|
+
case 'forms':
|
|
550
|
+
return matches(text, ['form', 'submit', 'validation', 'input']);
|
|
551
|
+
case 'checkout':
|
|
552
|
+
return matches(text, ['cart', 'checkout', 'order', 'payment']);
|
|
553
|
+
case 'content':
|
|
554
|
+
return matches(text, ['detail', 'article', 'product', 'profile']);
|
|
555
|
+
case 'accessibility':
|
|
556
|
+
return matches(text, ['accessibility', 'a11y', 'axe']);
|
|
557
|
+
case 'links':
|
|
558
|
+
return matches(text, ['link', 'broken link', '404', 'dead link']);
|
|
559
|
+
case 'cookies':
|
|
560
|
+
return matches(text, ['cookie', 'storage', 'privacy']);
|
|
561
|
+
default:
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function curateTestPlan(discovery, targetUrl, maxTests, hasAuthState, existingPlan = []) {
|
|
566
|
+
const prioritized = prioritize(discovery.testCases);
|
|
567
|
+
const used = new Set();
|
|
568
|
+
const synthesized = synthesizeTestCases(targetUrl, hasAuthState);
|
|
569
|
+
const existingTitles = new Set(existingPlan.map((p) => normalizeTitle(p.title ?? '')));
|
|
570
|
+
const existingCoverage = existingPlan.reduce((acc, p) => {
|
|
571
|
+
const area = p.coverageArea ?? 'content';
|
|
572
|
+
acc[area] = (acc[area] ?? 0) + 1;
|
|
573
|
+
return acc;
|
|
574
|
+
}, {
|
|
575
|
+
smoke: 0,
|
|
576
|
+
auth: 0,
|
|
577
|
+
navigation: 0,
|
|
578
|
+
search: 0,
|
|
579
|
+
forms: 0,
|
|
580
|
+
checkout: 0,
|
|
581
|
+
content: 0,
|
|
582
|
+
accessibility: 0,
|
|
583
|
+
links: 0,
|
|
584
|
+
cookies: 0,
|
|
585
|
+
});
|
|
586
|
+
const coverageOrder = [
|
|
587
|
+
{
|
|
588
|
+
area: 'smoke',
|
|
589
|
+
mandatory: existingCoverage.smoke === 0,
|
|
590
|
+
synthesize: true,
|
|
591
|
+
persona: 'guest',
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
area: 'auth',
|
|
595
|
+
mandatory: (discovery.siteProfile.requiresAuth || hasAuthState) &&
|
|
596
|
+
existingCoverage.auth === 0,
|
|
597
|
+
synthesize: hasAuthState,
|
|
598
|
+
persona: 'auth',
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
area: 'navigation',
|
|
602
|
+
mandatory: existingCoverage.navigation === 0,
|
|
603
|
+
synthesize: true,
|
|
604
|
+
persona: 'auto',
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
area: 'search',
|
|
608
|
+
mandatory: existingCoverage.search === 0,
|
|
609
|
+
synthesize: true,
|
|
610
|
+
persona: 'auto',
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
area: 'forms',
|
|
614
|
+
mandatory: existingCoverage.forms === 0,
|
|
615
|
+
synthesize: true,
|
|
616
|
+
persona: 'auto',
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
area: 'checkout',
|
|
620
|
+
mandatory: existingCoverage.checkout === 0,
|
|
621
|
+
synthesize: true,
|
|
622
|
+
persona: 'auto',
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
area: 'content',
|
|
626
|
+
mandatory: existingCoverage.content === 0,
|
|
627
|
+
synthesize: true,
|
|
628
|
+
persona: 'auto',
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
area: 'links',
|
|
632
|
+
mandatory: existingCoverage.links === 0,
|
|
633
|
+
synthesize: true,
|
|
634
|
+
persona: 'guest',
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
area: 'accessibility',
|
|
638
|
+
mandatory: existingCoverage.accessibility === 0,
|
|
639
|
+
synthesize: true,
|
|
640
|
+
persona: 'guest',
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
area: 'cookies',
|
|
644
|
+
mandatory: existingCoverage.cookies === 0,
|
|
645
|
+
synthesize: true,
|
|
646
|
+
persona: 'auto',
|
|
647
|
+
},
|
|
648
|
+
];
|
|
649
|
+
const planned = [];
|
|
650
|
+
const takeCase = (area, personaHint) => {
|
|
651
|
+
for (let i = 0; i < prioritized.length; i++) {
|
|
652
|
+
if (used.has(i))
|
|
653
|
+
continue;
|
|
654
|
+
const candidate = prioritized[i];
|
|
655
|
+
if (existingTitles.has(normalizeTitle(candidate.title ?? '')))
|
|
656
|
+
continue;
|
|
657
|
+
if (!matchesCoverage(candidate, area))
|
|
658
|
+
continue;
|
|
659
|
+
used.add(i);
|
|
660
|
+
const persona = personaHint === 'auto'
|
|
661
|
+
? pickPersona(candidate, hasAuthState, discovery.siteProfile.requiresAuth)
|
|
662
|
+
: personaHint;
|
|
663
|
+
return { ...candidate, coverageArea: area, persona, source: 'discovery' };
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
};
|
|
667
|
+
for (const coverage of coverageOrder) {
|
|
668
|
+
if (planned.length >= maxTests)
|
|
669
|
+
break;
|
|
670
|
+
const picked = takeCase(coverage.area, coverage.persona);
|
|
671
|
+
if (picked) {
|
|
672
|
+
planned.push(picked);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (coverage.synthesize &&
|
|
676
|
+
(coverage.mandatory || planned.length < maxTests)) {
|
|
677
|
+
const synth = synthesized[coverage.area];
|
|
678
|
+
planned.push({
|
|
679
|
+
...synth,
|
|
680
|
+
coverageArea: coverage.area,
|
|
681
|
+
persona: coverage.persona === 'auto'
|
|
682
|
+
? pickPersona(synth, hasAuthState, discovery.siteProfile.requiresAuth)
|
|
683
|
+
: coverage.persona,
|
|
684
|
+
source: 'synthetic',
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
for (let i = 0; i < prioritized.length && planned.length < maxTests; i++) {
|
|
689
|
+
if (used.has(i))
|
|
690
|
+
continue;
|
|
691
|
+
const candidate = prioritized[i];
|
|
692
|
+
if (existingTitles.has(normalizeTitle(candidate.title ?? '')))
|
|
693
|
+
continue;
|
|
694
|
+
planned.push({
|
|
695
|
+
...candidate,
|
|
696
|
+
coverageArea: 'content',
|
|
697
|
+
persona: pickPersona(candidate, hasAuthState, discovery.siteProfile.requiresAuth),
|
|
698
|
+
source: 'discovery',
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
return planned.slice(0, maxTests);
|
|
702
|
+
}
|
|
703
|
+
function formatFlowName(title, coverageArea, persona) {
|
|
704
|
+
const parts = [title];
|
|
705
|
+
if (coverageArea) {
|
|
706
|
+
parts.push(`[${coverageArea}]`);
|
|
707
|
+
}
|
|
708
|
+
if (persona) {
|
|
709
|
+
parts.push(`(${persona})`);
|
|
710
|
+
}
|
|
711
|
+
const combined = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
712
|
+
const sanitized = combined
|
|
713
|
+
.replace(/[^a-zA-Z0-9-_ ()[\]]/g, '-')
|
|
714
|
+
.replace(/-+/g, '-')
|
|
715
|
+
.replace(/-+$/, '')
|
|
716
|
+
.replace(/^-+/, '')
|
|
717
|
+
.trim();
|
|
718
|
+
const truncated = sanitized.length > 240 ? sanitized.slice(0, 240) : sanitized;
|
|
719
|
+
return truncated || 'Donobu Flow';
|
|
720
|
+
}
|
|
721
|
+
async function runGenerateSiteTests(opts, onProgress, shouldCancel) {
|
|
722
|
+
const { url, outDir, storageStatePath, maxTests = DEFAULT_TEST_COUNT, maxDiscoverySteps = DEFAULT_DISCOVERY_STEPS, maxTestSteps = DEFAULT_TEST_STEPS, gptConfigName, playwrightVariant = 'ai', headed = false, slowMo, disableSelfHeal = false, toolAllowlist, toolDenylist, } = opts;
|
|
723
|
+
const assertNotCancelled = (stage) => {
|
|
724
|
+
if (shouldCancel?.()) {
|
|
725
|
+
onProgress?.({
|
|
726
|
+
type: 'log',
|
|
727
|
+
level: 'warn',
|
|
728
|
+
message: `Cancelled during ${stage}`,
|
|
729
|
+
});
|
|
730
|
+
throw new Error('Cancelled');
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
assertNotCancelled('initialization');
|
|
734
|
+
const targetUrl = new node_url_1.URL(url).toString();
|
|
735
|
+
const projectSlug = (0, slugify_1.default)(new node_url_1.URL(targetUrl).hostname, {
|
|
736
|
+
lower: true,
|
|
737
|
+
strict: true,
|
|
738
|
+
});
|
|
739
|
+
const projectDir = await findProjectDir(projectSlug, outDir);
|
|
740
|
+
await promises_1.default.mkdir(projectDir, { recursive: true });
|
|
741
|
+
const existingPlan = await loadExistingPlan(projectDir);
|
|
742
|
+
assertNotCancelled('pre-flight setup');
|
|
743
|
+
Logger_1.appLogger.info(`📍 Target: ${targetUrl}`);
|
|
744
|
+
if (storageStatePath) {
|
|
745
|
+
Logger_1.appLogger.info('🔐 Using provided storage state for logged-in flows');
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
Logger_1.appLogger.info('🧭 No storage state provided; tests that require auth will be marked accordingly');
|
|
749
|
+
}
|
|
750
|
+
onProgress?.({
|
|
751
|
+
type: 'log',
|
|
752
|
+
level: 'info',
|
|
753
|
+
message: `Target ${targetUrl}; out ${projectDir}`,
|
|
754
|
+
});
|
|
755
|
+
if (toolAllowlist?.length || toolDenylist?.length) {
|
|
756
|
+
onProgress?.({
|
|
757
|
+
type: 'log',
|
|
758
|
+
level: 'info',
|
|
759
|
+
message: `Tool filters applied (allowlist: ${toolAllowlist?.join(', ') || 'none'}, denylist: ${toolDenylist?.join(', ') || 'none'})`,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
const storageState = await loadStorageState(storageStatePath);
|
|
763
|
+
assertNotCancelled('loading storage state');
|
|
764
|
+
const donobuStack = await (0, DonobuStack_1.setupDonobuStack)('LOCAL', ControlPanel_1.NoOpControlPanelFactory, new EnvPersistenceVolatile_1.EnvPersistenceVolatile(snapshotEnv()), envVars_1.env.pick('ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL_NAME', 'AWS_ACCESS_KEY_ID', 'AWS_BEDROCK_MODEL_NAME', 'AWS_REGION', 'AWS_S3_BUCKET', 'AWS_S3_REGION', 'AWS_SECRET_ACCESS_KEY', 'BASE64_GPT_CONFIG', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'DONOBU_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_GENERATIVE_AI_MODEL_NAME', 'GOOGLE_CLOUD_STORAGE_BUCKET', 'OPENAI_API_KEY', 'OPENAI_API_MODEL_NAME', 'SUPABASE_JWT_SECRET_KEY'));
|
|
765
|
+
await ensureGptAvailability(donobuStack, gptConfigName);
|
|
766
|
+
assertNotCancelled('ensuring GPT availability');
|
|
767
|
+
const allowedTools = buildAllowedTools(toolAllowlist, toolDenylist);
|
|
768
|
+
onProgress?.({
|
|
769
|
+
type: 'log',
|
|
770
|
+
level: 'info',
|
|
771
|
+
message: `Enabled tools: ${allowedTools.join(', ')}`,
|
|
772
|
+
});
|
|
773
|
+
const discoveryFlow = await donobuStack.flowsManager.createFlow({
|
|
774
|
+
targetWebsite: targetUrl,
|
|
775
|
+
overallObjective: buildDiscoveryObjective(targetUrl, maxTests, Boolean(storageState)),
|
|
776
|
+
name: formatFlowName(`Discovery for ${targetUrl}`),
|
|
777
|
+
allowedTools,
|
|
778
|
+
maxToolCalls: maxDiscoverySteps,
|
|
779
|
+
initialRunMode: 'AUTONOMOUS',
|
|
780
|
+
resultJsonSchema: buildDiscoverySchema(),
|
|
781
|
+
browser: buildBrowserConfig(storageState, headed, storageState ? 'auth' : 'guest'),
|
|
782
|
+
gptConfigNameOverride: gptConfigName ?? null,
|
|
783
|
+
videoDisabled: true,
|
|
784
|
+
});
|
|
785
|
+
let discoveryMetadata = null;
|
|
786
|
+
try {
|
|
787
|
+
discoveryMetadata = await waitForFlow(discoveryFlow, 'discovery flow', {
|
|
788
|
+
acceptStates: ['SUCCESS', 'FAILED'],
|
|
789
|
+
shouldCancel,
|
|
790
|
+
onCancel: async (flowId) => {
|
|
791
|
+
await donobuStack.flowsManager.cancelFlow(flowId);
|
|
792
|
+
},
|
|
793
|
+
}, onProgress);
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
Logger_1.appLogger.error('Failed to complete discovery flow', error);
|
|
797
|
+
onProgress?.({
|
|
798
|
+
type: 'log',
|
|
799
|
+
level: 'error',
|
|
800
|
+
message: `Discovery failed: ${error.message}`,
|
|
801
|
+
});
|
|
802
|
+
discoveryMetadata = null;
|
|
803
|
+
}
|
|
804
|
+
if (discoveryMetadata?.state === 'FAILED') {
|
|
805
|
+
Logger_1.appLogger.warn(`Discovery flow ended in state FAILED; continuing with synthesized tests and any partial data.`);
|
|
806
|
+
}
|
|
807
|
+
let discoveryResult = discoveryMetadata?.result ?? null;
|
|
808
|
+
if (!discoveryResult || !discoveryResult.testCases?.length) {
|
|
809
|
+
Logger_1.appLogger.warn('Discovery flow did not return any test cases; synthesizing baseline coverage.');
|
|
810
|
+
discoveryResult = {
|
|
811
|
+
siteProfile: {
|
|
812
|
+
siteType: 'Unknown',
|
|
813
|
+
primaryAudiences: [],
|
|
814
|
+
requiresAuth: Boolean(storageState),
|
|
815
|
+
keyJourneys: [],
|
|
816
|
+
riskAreas: [],
|
|
817
|
+
},
|
|
818
|
+
testCases: [],
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
if (discoveryResult.siteProfile.requiresAuth && !storageState) {
|
|
822
|
+
onProgress?.({
|
|
823
|
+
type: 'log',
|
|
824
|
+
level: 'warn',
|
|
825
|
+
message: 'Site appears to require authentication; no storage state provided. Auth journeys may be incomplete.',
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
const selectedTestCases = curateTestPlan(discoveryResult, targetUrl, maxTests, Boolean(storageState), existingPlan);
|
|
829
|
+
const mergedPlan = [...existingPlan, ...selectedTestCases];
|
|
830
|
+
await writePlanArtifacts(projectDir, discoveryResult, mergedPlan);
|
|
831
|
+
assertNotCancelled('writing plan artifacts');
|
|
832
|
+
const successfulFlowIds = [];
|
|
833
|
+
const flowAnnotationsMap = {};
|
|
834
|
+
for (const testCase of selectedTestCases) {
|
|
835
|
+
assertNotCancelled(`starting flow for ${testCase.title}`);
|
|
836
|
+
Logger_1.appLogger.info(`🧪 Running test flow: ${testCase.title} (${testCase.coverageArea}, ${testCase.persona})`);
|
|
837
|
+
onProgress?.({
|
|
838
|
+
type: 'log',
|
|
839
|
+
level: 'info',
|
|
840
|
+
message: `Running test: ${testCase.title}`,
|
|
841
|
+
});
|
|
842
|
+
const startUrl = testCase.startUrl || targetUrl;
|
|
843
|
+
const testFlow = await donobuStack.flowsManager.createFlow({
|
|
844
|
+
targetWebsite: startUrl,
|
|
845
|
+
overallObjective: buildTestObjective(testCase, testCase.persona, discoveryResult.siteProfile, testCase.coverageArea),
|
|
846
|
+
name: formatFlowName(testCase.title, testCase.coverageArea, testCase.persona),
|
|
847
|
+
allowedTools,
|
|
848
|
+
maxToolCalls: maxTestSteps,
|
|
849
|
+
initialRunMode: 'AUTONOMOUS',
|
|
850
|
+
browser: buildBrowserConfig(storageState, headed, testCase.persona),
|
|
851
|
+
gptConfigNameOverride: gptConfigName ?? null,
|
|
852
|
+
videoDisabled: true,
|
|
853
|
+
toolCallsOnStart: [
|
|
854
|
+
{
|
|
855
|
+
name: GoToWebpageTool_1.GoToWebpageTool.NAME,
|
|
856
|
+
parameters: {
|
|
857
|
+
url: startUrl,
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
],
|
|
861
|
+
});
|
|
862
|
+
try {
|
|
863
|
+
const metadata = await waitForFlow(testFlow, testCase.title, {
|
|
864
|
+
shouldCancel,
|
|
865
|
+
onCancel: async (flowId) => {
|
|
866
|
+
await donobuStack.flowsManager.cancelFlow(flowId);
|
|
867
|
+
},
|
|
868
|
+
}, onProgress);
|
|
869
|
+
flowAnnotationsMap[metadata.id] = [
|
|
870
|
+
{ type: 'coverage', description: testCase.coverageArea },
|
|
871
|
+
{ type: 'persona', description: testCase.persona },
|
|
872
|
+
{ type: 'priority', description: testCase.priority ?? 'unspecified' },
|
|
873
|
+
{ type: 'category', description: testCase.category ?? 'unspecified' },
|
|
874
|
+
];
|
|
875
|
+
successfulFlowIds.push(metadata.id);
|
|
876
|
+
}
|
|
877
|
+
catch (error) {
|
|
878
|
+
Logger_1.appLogger.error(`Test flow failed for "${testCase.title}"`, error);
|
|
879
|
+
onProgress?.({
|
|
880
|
+
type: 'log',
|
|
881
|
+
level: 'error',
|
|
882
|
+
message: `Test flow failed for "${testCase.title}": ${error.message}`,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (successfulFlowIds.length === 0) {
|
|
887
|
+
throw new Error('No test flows completed successfully; nothing to generate');
|
|
888
|
+
}
|
|
889
|
+
const codegenOptions = {
|
|
890
|
+
playwrightScriptVariant: playwrightVariant,
|
|
891
|
+
disableSelfHealingTests: disableSelfHeal,
|
|
892
|
+
runInHeadedMode: headed,
|
|
893
|
+
...(slowMo !== undefined ? { slowMotionDelay: slowMo } : {}),
|
|
894
|
+
flowAnnotations: flowAnnotationsMap,
|
|
895
|
+
};
|
|
896
|
+
const project = await donobuStack.flowsManager.getFlowsAsPlaywrightProject(successfulFlowIds, codegenOptions);
|
|
897
|
+
assertNotCancelled('building playwright project');
|
|
898
|
+
await writeProject(projectDir, project.files);
|
|
899
|
+
if (existingPlan.length > 0) {
|
|
900
|
+
const catchAllConfig = await (0, CodeGenerator_1.prettifyCode)(buildCatchAllPlaywrightConfig(codegenOptions));
|
|
901
|
+
assertNotCancelled('writing Playwright config');
|
|
902
|
+
await promises_1.default.writeFile(path_1.default.join(projectDir, 'playwright.config.ts'), catchAllConfig, 'utf8');
|
|
903
|
+
}
|
|
904
|
+
onProgress?.({
|
|
905
|
+
type: 'summary',
|
|
906
|
+
data: {
|
|
907
|
+
projectDir,
|
|
908
|
+
newlyGeneratedTests: successfulFlowIds.length,
|
|
909
|
+
totalPlannedTests: mergedPlan.length,
|
|
910
|
+
requiresAuth: discoveryResult.siteProfile.requiresAuth,
|
|
911
|
+
usedStorageState: Boolean(storageState),
|
|
912
|
+
siteProfile: discoveryResult.siteProfile,
|
|
913
|
+
},
|
|
914
|
+
});
|
|
915
|
+
Logger_1.appLogger.info(`✅ Generated Playwright project in ${projectDir}`);
|
|
916
|
+
onProgress?.({
|
|
917
|
+
type: 'log',
|
|
918
|
+
level: 'info',
|
|
919
|
+
message: `Generated project in ${projectDir}`,
|
|
920
|
+
});
|
|
921
|
+
Logger_1.appLogger.info(`ℹ️ Install deps with "npm install" then run tests with "npx donobu test" (or "npx donobu test --auto-heal").`);
|
|
922
|
+
}
|
|
923
|
+
//# sourceMappingURL=runGenerateSiteTests.js.map
|