intellitester 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -0
- package/dist/chunk-35WJGNDA.cjs +136 -0
- package/dist/chunk-35WJGNDA.cjs.map +1 -0
- package/dist/chunk-4B54JUOP.js +234 -0
- package/dist/chunk-4B54JUOP.js.map +1 -0
- package/dist/chunk-5LFSLMQ7.js +2517 -0
- package/dist/chunk-5LFSLMQ7.js.map +1 -0
- package/dist/chunk-6PYKWWH5.js +63 -0
- package/dist/chunk-6PYKWWH5.js.map +1 -0
- package/dist/chunk-ARJYJVRM.cjs +302 -0
- package/dist/chunk-ARJYJVRM.cjs.map +1 -0
- package/dist/chunk-CN6HSJJX.js +133 -0
- package/dist/chunk-CN6HSJJX.js.map +1 -0
- package/dist/chunk-DE5UFTTG.js +31 -0
- package/dist/chunk-DE5UFTTG.js.map +1 -0
- package/dist/chunk-ECBA4GJ3.js +287 -0
- package/dist/chunk-ECBA4GJ3.js.map +1 -0
- package/dist/chunk-OFXNJXMV.cjs +237 -0
- package/dist/chunk-OFXNJXMV.cjs.map +1 -0
- package/dist/chunk-PAKODOH4.cjs +66 -0
- package/dist/chunk-PAKODOH4.cjs.map +1 -0
- package/dist/chunk-QMYM2TCH.cjs +36 -0
- package/dist/chunk-QMYM2TCH.cjs.map +1 -0
- package/dist/chunk-SAVY6D3X.js +125 -0
- package/dist/chunk-SAVY6D3X.js.map +1 -0
- package/dist/chunk-UUJXCHVT.cjs +128 -0
- package/dist/chunk-UUJXCHVT.cjs.map +1 -0
- package/dist/chunk-XWGUA67E.cjs +2552 -0
- package/dist/chunk-XWGUA67E.cjs.map +1 -0
- package/dist/cli/index.cjs +1985 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1957 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/cleanup/index.cjs +45 -0
- package/dist/core/cleanup/index.cjs.map +1 -0
- package/dist/core/cleanup/index.d.cts +117 -0
- package/dist/core/cleanup/index.d.ts +117 -0
- package/dist/core/cleanup/index.js +8 -0
- package/dist/core/cleanup/index.js.map +1 -0
- package/dist/index.cjs +110 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +852 -0
- package/dist/index.d.ts +852 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/integration/index.cjs +22 -0
- package/dist/integration/index.cjs.map +1 -0
- package/dist/integration/index.d.cts +42 -0
- package/dist/integration/index.d.ts +42 -0
- package/dist/integration/index.js +20 -0
- package/dist/integration/index.js.map +1 -0
- package/dist/providers/appwrite/index.cjs +16 -0
- package/dist/providers/appwrite/index.cjs.map +1 -0
- package/dist/providers/appwrite/index.d.cts +12 -0
- package/dist/providers/appwrite/index.d.ts +12 -0
- package/dist/providers/appwrite/index.js +3 -0
- package/dist/providers/appwrite/index.js.map +1 -0
- package/dist/providers/index.cjs +60 -0
- package/dist/providers/index.cjs.map +1 -0
- package/dist/providers/index.d.cts +13 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.js +7 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/mysql/index.cjs +16 -0
- package/dist/providers/mysql/index.cjs.map +1 -0
- package/dist/providers/mysql/index.d.cts +14 -0
- package/dist/providers/mysql/index.d.ts +14 -0
- package/dist/providers/mysql/index.js +3 -0
- package/dist/providers/mysql/index.js.map +1 -0
- package/dist/providers/postgres/index.cjs +16 -0
- package/dist/providers/postgres/index.cjs.map +1 -0
- package/dist/providers/postgres/index.d.cts +10 -0
- package/dist/providers/postgres/index.d.ts +10 -0
- package/dist/providers/postgres/index.js +3 -0
- package/dist/providers/postgres/index.js.map +1 -0
- package/dist/providers/sqlite/index.cjs +16 -0
- package/dist/providers/sqlite/index.cjs.map +1 -0
- package/dist/providers/sqlite/index.d.cts +11 -0
- package/dist/providers/sqlite/index.d.ts +11 -0
- package/dist/providers/sqlite/index.js +3 -0
- package/dist/providers/sqlite/index.js.map +1 -0
- package/dist/types-LONNVTIF.d.cts +56 -0
- package/dist/types-l-ZaFKC-.d.ts +56 -0
- package/package.json +114 -0
- package/schemas/intellitester.config.schema.json +384 -0
- package/schemas/test.schema.json +517 -0
- package/schemas/workflow.schema.json +227 -0
|
@@ -0,0 +1,1957 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadIntellitesterConfig, isPipelineFile, isWorkflowFile, loadTestDefinition, collectMissingEnvVars, loadPipelineDefinition, loadWorkflowDefinition, runWorkflow, runWebTest, startTrackingServer, startWebServer, createTestContext, setupAppwriteTracking, runWorkflowWithContext, killServer, createAIProvider, TestDefinitionSchema } from '../chunk-5LFSLMQ7.js';
|
|
3
|
+
import { loadFailedCleanups, loadCleanupHandlers, executeCleanup, removeFailedCleanup } from '../chunk-ECBA4GJ3.js';
|
|
4
|
+
import '../chunk-DE5UFTTG.js';
|
|
5
|
+
import '../chunk-6PYKWWH5.js';
|
|
6
|
+
import '../chunk-4B54JUOP.js';
|
|
7
|
+
import '../chunk-SAVY6D3X.js';
|
|
8
|
+
import '../chunk-CN6HSJJX.js';
|
|
9
|
+
import dotenv2 from 'dotenv';
|
|
10
|
+
import fs3 from 'fs/promises';
|
|
11
|
+
import * as path4 from 'path';
|
|
12
|
+
import path4__default from 'path';
|
|
13
|
+
import process2 from 'process';
|
|
14
|
+
import { Command } from 'commander';
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
import crypto from 'crypto';
|
|
17
|
+
import { chromium, webkit, firefox } from 'playwright';
|
|
18
|
+
import { parse } from 'yaml';
|
|
19
|
+
import { glob } from 'glob';
|
|
20
|
+
import { promises } from 'fs';
|
|
21
|
+
import prompts from 'prompts';
|
|
22
|
+
|
|
23
|
+
var getBrowser = (browser) => {
|
|
24
|
+
switch (browser) {
|
|
25
|
+
case "firefox":
|
|
26
|
+
return firefox;
|
|
27
|
+
case "webkit":
|
|
28
|
+
return webkit;
|
|
29
|
+
default:
|
|
30
|
+
return chromium;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
function buildExecutionOrder(workflows) {
|
|
34
|
+
const workflowMap = /* @__PURE__ */ new Map();
|
|
35
|
+
const workflowIds = [];
|
|
36
|
+
for (let i = 0; i < workflows.length; i++) {
|
|
37
|
+
const workflow = workflows[i];
|
|
38
|
+
const id = workflow.id ?? `workflow_${i}`;
|
|
39
|
+
workflowIds.push(id);
|
|
40
|
+
workflowMap.set(id, { ...workflow, id });
|
|
41
|
+
}
|
|
42
|
+
const adjacencyList = /* @__PURE__ */ new Map();
|
|
43
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const id of workflowIds) {
|
|
45
|
+
adjacencyList.set(id, []);
|
|
46
|
+
inDegree.set(id, 0);
|
|
47
|
+
}
|
|
48
|
+
for (const id of workflowIds) {
|
|
49
|
+
const workflow = workflowMap.get(id);
|
|
50
|
+
const deps = workflow.depends_on ?? [];
|
|
51
|
+
for (const depId of deps) {
|
|
52
|
+
if (!workflowMap.has(depId)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Workflow "${id}" depends on "${depId}" which does not exist in the pipeline`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
adjacencyList.get(depId).push(id);
|
|
58
|
+
inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const queue = [];
|
|
62
|
+
for (const id of workflowIds) {
|
|
63
|
+
if (inDegree.get(id) === 0) {
|
|
64
|
+
queue.push(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const sorted = [];
|
|
68
|
+
while (queue.length > 0) {
|
|
69
|
+
const currentId = queue.shift();
|
|
70
|
+
sorted.push(workflowMap.get(currentId));
|
|
71
|
+
for (const dependentId of adjacencyList.get(currentId) ?? []) {
|
|
72
|
+
const newInDegree = (inDegree.get(dependentId) ?? 1) - 1;
|
|
73
|
+
inDegree.set(dependentId, newInDegree);
|
|
74
|
+
if (newInDegree === 0) {
|
|
75
|
+
queue.push(dependentId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (sorted.length !== workflowIds.length) {
|
|
80
|
+
const remaining = workflowIds.filter((id) => !sorted.some((w) => w.id === id));
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Circular dependency detected in pipeline. Workflows involved: ${remaining.join(", ")}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return sorted;
|
|
86
|
+
}
|
|
87
|
+
function inferCleanupConfig(config) {
|
|
88
|
+
if (!config) return void 0;
|
|
89
|
+
if (config.cleanup) {
|
|
90
|
+
return config.cleanup;
|
|
91
|
+
}
|
|
92
|
+
if (config.appwrite?.cleanup) {
|
|
93
|
+
return {
|
|
94
|
+
provider: "appwrite",
|
|
95
|
+
appwrite: {
|
|
96
|
+
endpoint: config.appwrite.endpoint,
|
|
97
|
+
projectId: config.appwrite.projectId,
|
|
98
|
+
apiKey: config.appwrite.apiKey,
|
|
99
|
+
cleanupOnFailure: config.appwrite.cleanupOnFailure
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return void 0;
|
|
104
|
+
}
|
|
105
|
+
async function runPipeline(pipeline, pipelinePath, options = {}) {
|
|
106
|
+
const pipelineDir = path4__default.dirname(pipelinePath);
|
|
107
|
+
const sessionId = crypto.randomUUID();
|
|
108
|
+
const testStartTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
109
|
+
console.log(`
|
|
110
|
+
${"=".repeat(60)}`);
|
|
111
|
+
console.log(`Pipeline: ${pipeline.name}`);
|
|
112
|
+
console.log(`Session ID: ${sessionId}`);
|
|
113
|
+
console.log(`${"=".repeat(60)}
|
|
114
|
+
`);
|
|
115
|
+
let executionOrder;
|
|
116
|
+
try {
|
|
117
|
+
executionOrder = buildExecutionOrder(pipeline.workflows);
|
|
118
|
+
console.log(
|
|
119
|
+
`Execution order: ${executionOrder.map((w) => w.id ?? w.file).join(" -> ")}
|
|
120
|
+
`
|
|
121
|
+
);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
console.error(`Failed to build execution order: ${message}`);
|
|
125
|
+
return {
|
|
126
|
+
status: "failed",
|
|
127
|
+
workflows: [],
|
|
128
|
+
sessionId
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
let trackingServer = null;
|
|
132
|
+
try {
|
|
133
|
+
trackingServer = await startTrackingServer({ port: 0 });
|
|
134
|
+
console.log(`Tracking server started on port ${trackingServer.port}`);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.warn("Failed to start tracking server:", error);
|
|
137
|
+
}
|
|
138
|
+
if (trackingServer) {
|
|
139
|
+
process.env.INTELLITESTER_SESSION_ID = sessionId;
|
|
140
|
+
process.env.INTELLITESTER_TRACK_URL = `http://localhost:${trackingServer.port}`;
|
|
141
|
+
}
|
|
142
|
+
let serverProcess = null;
|
|
143
|
+
if (pipeline.config?.webServer) {
|
|
144
|
+
try {
|
|
145
|
+
serverProcess = await startWebServer({
|
|
146
|
+
...pipeline.config.webServer,
|
|
147
|
+
cwd: pipelineDir
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error("Failed to start web server:", error);
|
|
151
|
+
if (trackingServer) await trackingServer.stop();
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const signalCleanup = async () => {
|
|
156
|
+
console.log("\n\nInterrupted - cleaning up...");
|
|
157
|
+
killServer(serverProcess);
|
|
158
|
+
if (trackingServer) await trackingServer.stop();
|
|
159
|
+
process.exit(1);
|
|
160
|
+
};
|
|
161
|
+
process.on("SIGINT", signalCleanup);
|
|
162
|
+
process.on("SIGTERM", signalCleanup);
|
|
163
|
+
const browserName = options.browser ?? pipeline.config?.web?.browser ?? "chromium";
|
|
164
|
+
const headless = options.headed ? false : pipeline.config?.web?.headless ?? true;
|
|
165
|
+
const browser = await getBrowser(browserName).launch({ headless });
|
|
166
|
+
const browserContext = await browser.newContext();
|
|
167
|
+
const page = await browserContext.newPage();
|
|
168
|
+
page.setDefaultTimeout(3e4);
|
|
169
|
+
const executionContext = {
|
|
170
|
+
variables: /* @__PURE__ */ new Map(),
|
|
171
|
+
lastEmail: null,
|
|
172
|
+
emailClient: null,
|
|
173
|
+
appwriteContext: createTestContext(),
|
|
174
|
+
appwriteConfig: pipeline.config?.appwrite ? {
|
|
175
|
+
endpoint: pipeline.config.appwrite.endpoint,
|
|
176
|
+
projectId: pipeline.config.appwrite.projectId,
|
|
177
|
+
apiKey: pipeline.config.appwrite.apiKey
|
|
178
|
+
} : void 0
|
|
179
|
+
};
|
|
180
|
+
if (pipeline.config?.appwrite) {
|
|
181
|
+
setupAppwriteTracking(page, executionContext);
|
|
182
|
+
}
|
|
183
|
+
const completedIds = /* @__PURE__ */ new Set();
|
|
184
|
+
const failedIds = /* @__PURE__ */ new Set();
|
|
185
|
+
const skippedIds = /* @__PURE__ */ new Set();
|
|
186
|
+
const workflowResults = [];
|
|
187
|
+
let pipelineFailed = false;
|
|
188
|
+
let shouldStopPipeline = false;
|
|
189
|
+
try {
|
|
190
|
+
for (const workflowRef of executionOrder) {
|
|
191
|
+
const workflowId = workflowRef.id ?? workflowRef.file;
|
|
192
|
+
if (shouldStopPipeline) {
|
|
193
|
+
workflowResults.push({
|
|
194
|
+
id: workflowRef.id,
|
|
195
|
+
file: workflowRef.file,
|
|
196
|
+
status: "skipped",
|
|
197
|
+
error: "Pipeline stopped due to previous failure"
|
|
198
|
+
});
|
|
199
|
+
skippedIds.add(workflowId);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const deps = workflowRef.depends_on ?? [];
|
|
203
|
+
const depsFailed = deps.some((id) => failedIds.has(id) || skippedIds.has(id));
|
|
204
|
+
const depsNotMet = deps.some(
|
|
205
|
+
(id) => !completedIds.has(id) && !failedIds.has(id) && !skippedIds.has(id)
|
|
206
|
+
);
|
|
207
|
+
if (depsFailed || depsNotMet) {
|
|
208
|
+
const onFailure = workflowRef.on_failure ?? pipeline.on_failure;
|
|
209
|
+
if (onFailure === "skip") {
|
|
210
|
+
console.log(`
|
|
211
|
+
Skipping workflow "${workflowId}" - dependencies not met`);
|
|
212
|
+
workflowResults.push({
|
|
213
|
+
id: workflowRef.id,
|
|
214
|
+
file: workflowRef.file,
|
|
215
|
+
status: "skipped",
|
|
216
|
+
error: `Dependencies not met: ${deps.filter((d) => failedIds.has(d) || skippedIds.has(d)).join(", ")}`
|
|
217
|
+
});
|
|
218
|
+
skippedIds.add(workflowId);
|
|
219
|
+
continue;
|
|
220
|
+
} else if (onFailure === "fail") {
|
|
221
|
+
console.log(
|
|
222
|
+
`
|
|
223
|
+
Pipeline stopped - workflow "${workflowId}" dependencies failed`
|
|
224
|
+
);
|
|
225
|
+
workflowResults.push({
|
|
226
|
+
id: workflowRef.id,
|
|
227
|
+
file: workflowRef.file,
|
|
228
|
+
status: "failed",
|
|
229
|
+
error: `Dependencies failed: ${deps.filter((d) => failedIds.has(d) || skippedIds.has(d)).join(", ")}`
|
|
230
|
+
});
|
|
231
|
+
failedIds.add(workflowId);
|
|
232
|
+
pipelineFailed = true;
|
|
233
|
+
shouldStopPipeline = true;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
console.log(
|
|
237
|
+
`
|
|
238
|
+
Running workflow "${workflowId}" despite dependency failure (on_failure: ignore)`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const workflowFilePath = path4__default.resolve(pipelineDir, workflowRef.file);
|
|
242
|
+
console.log(`
|
|
243
|
+
${"=".repeat(40)}`);
|
|
244
|
+
console.log(`Workflow: ${workflowId}`);
|
|
245
|
+
console.log(`File: ${workflowRef.file}`);
|
|
246
|
+
console.log(`${"=".repeat(40)}`);
|
|
247
|
+
try {
|
|
248
|
+
const workflowDefinition = await loadWorkflowDefinition(workflowFilePath);
|
|
249
|
+
if (workflowRef.variables) {
|
|
250
|
+
for (const [key, value] of Object.entries(workflowRef.variables)) {
|
|
251
|
+
const interpolated = value.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
|
252
|
+
if (varName === "uuid") {
|
|
253
|
+
return crypto.randomUUID().split("-")[0];
|
|
254
|
+
}
|
|
255
|
+
return executionContext.variables.get(varName) ?? match;
|
|
256
|
+
});
|
|
257
|
+
executionContext.variables.set(key, interpolated);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const workflowOptions = {
|
|
261
|
+
...options,
|
|
262
|
+
page,
|
|
263
|
+
executionContext,
|
|
264
|
+
skipCleanup: true,
|
|
265
|
+
// Defer cleanup to pipeline end
|
|
266
|
+
sessionId,
|
|
267
|
+
testStartTime
|
|
268
|
+
};
|
|
269
|
+
const result = await runWorkflowWithContext(
|
|
270
|
+
workflowDefinition,
|
|
271
|
+
workflowFilePath,
|
|
272
|
+
workflowOptions
|
|
273
|
+
);
|
|
274
|
+
if (result.status === "passed") {
|
|
275
|
+
completedIds.add(workflowId);
|
|
276
|
+
workflowResults.push({
|
|
277
|
+
id: workflowRef.id,
|
|
278
|
+
file: workflowRef.file,
|
|
279
|
+
status: "passed",
|
|
280
|
+
workflowResult: result
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
failedIds.add(workflowId);
|
|
284
|
+
pipelineFailed = true;
|
|
285
|
+
workflowResults.push({
|
|
286
|
+
id: workflowRef.id,
|
|
287
|
+
file: workflowRef.file,
|
|
288
|
+
status: "failed",
|
|
289
|
+
workflowResult: result,
|
|
290
|
+
error: result.tests.find((t) => t.status === "failed")?.error
|
|
291
|
+
});
|
|
292
|
+
const onFailure = workflowRef.on_failure ?? pipeline.on_failure;
|
|
293
|
+
if (onFailure === "fail") {
|
|
294
|
+
console.log(`
|
|
295
|
+
Pipeline stopped due to workflow "${workflowId}" failure`);
|
|
296
|
+
shouldStopPipeline = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
301
|
+
console.error(`Failed to load/run workflow "${workflowId}": ${message}`);
|
|
302
|
+
failedIds.add(workflowId);
|
|
303
|
+
pipelineFailed = true;
|
|
304
|
+
workflowResults.push({
|
|
305
|
+
id: workflowRef.id,
|
|
306
|
+
file: workflowRef.file,
|
|
307
|
+
status: "failed",
|
|
308
|
+
error: message
|
|
309
|
+
});
|
|
310
|
+
const onFailure = workflowRef.on_failure ?? pipeline.on_failure;
|
|
311
|
+
if (onFailure === "fail") {
|
|
312
|
+
console.log(`
|
|
313
|
+
Pipeline stopped due to workflow "${workflowId}" failure`);
|
|
314
|
+
shouldStopPipeline = true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (trackingServer) {
|
|
319
|
+
const serverResources = trackingServer.getResources(sessionId);
|
|
320
|
+
if (serverResources.length > 0) {
|
|
321
|
+
console.log(`
|
|
322
|
+
Collected ${serverResources.length} server-tracked resources`);
|
|
323
|
+
executionContext.appwriteContext.resources.push(...serverResources);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
let cleanupResult;
|
|
327
|
+
const cleanupConfig = inferCleanupConfig(pipeline.config);
|
|
328
|
+
if (cleanupConfig) {
|
|
329
|
+
const shouldCleanup = pipelineFailed ? pipeline.cleanup_on_failure : true;
|
|
330
|
+
if (shouldCleanup) {
|
|
331
|
+
try {
|
|
332
|
+
console.log("\n---");
|
|
333
|
+
console.log("[Cleanup] Starting pipeline cleanup...");
|
|
334
|
+
const { handlers, typeMappings, provider } = await loadCleanupHandlers(
|
|
335
|
+
cleanupConfig,
|
|
336
|
+
process.cwd()
|
|
337
|
+
);
|
|
338
|
+
const genericResources = executionContext.appwriteContext.resources.map(
|
|
339
|
+
(r) => ({ ...r })
|
|
340
|
+
);
|
|
341
|
+
const providerConfig = {
|
|
342
|
+
provider: cleanupConfig.provider || "appwrite"
|
|
343
|
+
};
|
|
344
|
+
if (cleanupConfig.provider === "appwrite" && cleanupConfig.appwrite) {
|
|
345
|
+
const appwriteCleanupConfig = cleanupConfig.appwrite;
|
|
346
|
+
providerConfig.endpoint = appwriteCleanupConfig.endpoint;
|
|
347
|
+
providerConfig.projectId = appwriteCleanupConfig.projectId;
|
|
348
|
+
}
|
|
349
|
+
cleanupResult = await executeCleanup(
|
|
350
|
+
genericResources,
|
|
351
|
+
handlers,
|
|
352
|
+
typeMappings,
|
|
353
|
+
{
|
|
354
|
+
parallel: cleanupConfig.parallel ?? false,
|
|
355
|
+
retries: cleanupConfig.retries ?? 3,
|
|
356
|
+
sessionId,
|
|
357
|
+
testStartTime,
|
|
358
|
+
userId: executionContext.appwriteContext.userId,
|
|
359
|
+
providerConfig,
|
|
360
|
+
cwd: process.cwd(),
|
|
361
|
+
config: cleanupConfig,
|
|
362
|
+
provider
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
if (cleanupResult.success) {
|
|
366
|
+
console.log(
|
|
367
|
+
`[Cleanup] Cleanup complete: ${cleanupResult.deleted.length} resources deleted`
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
console.log(
|
|
371
|
+
`[Cleanup] Cleanup partial: ${cleanupResult.deleted.length} deleted, ${cleanupResult.failed.length} failed`
|
|
372
|
+
);
|
|
373
|
+
for (const failed of cleanupResult.failed) {
|
|
374
|
+
console.log(` - ${failed}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
console.error("[Cleanup] Cleanup failed:", error);
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
console.log("\nSkipping cleanup (cleanup_on_failure is false)");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const passedCount = workflowResults.filter((w) => w.status === "passed").length;
|
|
385
|
+
const failedCount = workflowResults.filter((w) => w.status === "failed").length;
|
|
386
|
+
const skippedCount = workflowResults.filter((w) => w.status === "skipped").length;
|
|
387
|
+
console.log(`
|
|
388
|
+
${"=".repeat(60)}`);
|
|
389
|
+
console.log(`Pipeline: ${pipelineFailed ? "FAILED" : "PASSED"}`);
|
|
390
|
+
console.log(
|
|
391
|
+
`Workflows: ${passedCount} passed, ${failedCount} failed, ${skippedCount} skipped`
|
|
392
|
+
);
|
|
393
|
+
console.log(`${"=".repeat(60)}
|
|
394
|
+
`);
|
|
395
|
+
return {
|
|
396
|
+
status: pipelineFailed ? "failed" : "passed",
|
|
397
|
+
workflows: workflowResults,
|
|
398
|
+
sessionId,
|
|
399
|
+
cleanupResult
|
|
400
|
+
};
|
|
401
|
+
} finally {
|
|
402
|
+
process.off("SIGINT", signalCleanup);
|
|
403
|
+
process.off("SIGTERM", signalCleanup);
|
|
404
|
+
await browserContext.close();
|
|
405
|
+
await browser.close();
|
|
406
|
+
killServer(serverProcess);
|
|
407
|
+
if (trackingServer) {
|
|
408
|
+
await trackingServer.stop();
|
|
409
|
+
}
|
|
410
|
+
delete process.env.INTELLITESTER_SESSION_ID;
|
|
411
|
+
delete process.env.INTELLITESTER_TRACK_URL;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/generator/elementExtractor.ts
|
|
416
|
+
var INTERACTIVE_TAGS = [
|
|
417
|
+
"button",
|
|
418
|
+
"input",
|
|
419
|
+
"textarea",
|
|
420
|
+
"select",
|
|
421
|
+
"a",
|
|
422
|
+
"form",
|
|
423
|
+
"label",
|
|
424
|
+
"option"
|
|
425
|
+
];
|
|
426
|
+
function extractTemplateContent(content, filePath) {
|
|
427
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
428
|
+
switch (ext) {
|
|
429
|
+
case "vue": {
|
|
430
|
+
const templateMatch = content.match(/<template[^>]*>([\s\S]*?)<\/template>/);
|
|
431
|
+
return templateMatch ? templateMatch[1] : "";
|
|
432
|
+
}
|
|
433
|
+
case "astro": {
|
|
434
|
+
const frontmatterEnd = content.lastIndexOf("---");
|
|
435
|
+
if (frontmatterEnd > 0) {
|
|
436
|
+
return content.substring(frontmatterEnd + 3);
|
|
437
|
+
}
|
|
438
|
+
return content;
|
|
439
|
+
}
|
|
440
|
+
case "tsx":
|
|
441
|
+
case "jsx": {
|
|
442
|
+
const jsxPatterns = [
|
|
443
|
+
// return ( ... )
|
|
444
|
+
/return\s*\(([\s\S]*?)\);/g,
|
|
445
|
+
// return <...>
|
|
446
|
+
/return\s+(<[\s\S]*?>[\s\S]*?<\/[\w.]+>)/g,
|
|
447
|
+
// return <... />
|
|
448
|
+
/return\s+(<[^>]+\/\s*>)/g
|
|
449
|
+
];
|
|
450
|
+
const jsxParts = [];
|
|
451
|
+
for (const pattern of jsxPatterns) {
|
|
452
|
+
const matches = content.matchAll(pattern);
|
|
453
|
+
for (const match of matches) {
|
|
454
|
+
jsxParts.push(match[1]);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return jsxParts.join("\n");
|
|
458
|
+
}
|
|
459
|
+
case "svelte": {
|
|
460
|
+
let template = content;
|
|
461
|
+
template = template.replace(/<script[^>]*>[\s\S]*?<\/script>/g, "");
|
|
462
|
+
template = template.replace(/<style[^>]*>[\s\S]*?<\/style>/g, "");
|
|
463
|
+
return template;
|
|
464
|
+
}
|
|
465
|
+
default:
|
|
466
|
+
return content;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function extractAttribute(element, attrName) {
|
|
470
|
+
const patterns = [
|
|
471
|
+
new RegExp(`${attrName}\\s*=\\s*"([^"]*)"`, "i"),
|
|
472
|
+
new RegExp(`${attrName}\\s*=\\s*'([^']*)'`, "i"),
|
|
473
|
+
new RegExp(`${attrName}\\s*=\\s*{([^}]*)}`, "i")
|
|
474
|
+
// JSX expressions
|
|
475
|
+
];
|
|
476
|
+
for (const pattern of patterns) {
|
|
477
|
+
const match = element.match(pattern);
|
|
478
|
+
if (match && match[1]) {
|
|
479
|
+
let value = match[1].trim();
|
|
480
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
481
|
+
value = value.slice(1, -1);
|
|
482
|
+
} else if (value.startsWith("'") && value.endsWith("'")) {
|
|
483
|
+
value = value.slice(1, -1);
|
|
484
|
+
}
|
|
485
|
+
return value;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return void 0;
|
|
489
|
+
}
|
|
490
|
+
function extractTextContent(element, tag) {
|
|
491
|
+
const regex = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`, "i");
|
|
492
|
+
const match = element.match(regex);
|
|
493
|
+
if (match && match[1]) {
|
|
494
|
+
let text = match[1].trim();
|
|
495
|
+
text = text.replace(/\{[^}]*\}/g, "").trim();
|
|
496
|
+
if (text.length > 0 && text.length < 100) {
|
|
497
|
+
return text;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return void 0;
|
|
501
|
+
}
|
|
502
|
+
function generateDescription(element) {
|
|
503
|
+
const parts = [];
|
|
504
|
+
if (element.tag === "input" && element.type) {
|
|
505
|
+
parts.push(`${element.type} input`);
|
|
506
|
+
} else if (element.tag === "a") {
|
|
507
|
+
parts.push("link");
|
|
508
|
+
} else if (element.tag) {
|
|
509
|
+
parts.push(element.tag);
|
|
510
|
+
}
|
|
511
|
+
if (element.text) {
|
|
512
|
+
parts.push(`"${element.text}"`);
|
|
513
|
+
} else if (element.name) {
|
|
514
|
+
parts.push(`"${element.name}"`);
|
|
515
|
+
} else if (element.placeholder) {
|
|
516
|
+
parts.push(`with placeholder "${element.placeholder}"`);
|
|
517
|
+
} else if (element.testId) {
|
|
518
|
+
parts.push(`(${element.testId})`);
|
|
519
|
+
}
|
|
520
|
+
return parts.join(" ");
|
|
521
|
+
}
|
|
522
|
+
function parseElement(elementStr, file, route) {
|
|
523
|
+
const tagMatch = elementStr.match(/<(\w+)/);
|
|
524
|
+
if (!tagMatch) return null;
|
|
525
|
+
const tag = tagMatch[1].toLowerCase();
|
|
526
|
+
if (!INTERACTIVE_TAGS.includes(tag)) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
const element = {
|
|
530
|
+
tag,
|
|
531
|
+
file,
|
|
532
|
+
route
|
|
533
|
+
};
|
|
534
|
+
element.testId = extractAttribute(elementStr, "data-testid");
|
|
535
|
+
element.role = extractAttribute(elementStr, "role");
|
|
536
|
+
element.name = extractAttribute(elementStr, "aria-label");
|
|
537
|
+
element.type = extractAttribute(elementStr, "type");
|
|
538
|
+
element.placeholder = extractAttribute(elementStr, "placeholder");
|
|
539
|
+
if (!element.name) {
|
|
540
|
+
element.name = extractAttribute(elementStr, "name");
|
|
541
|
+
}
|
|
542
|
+
if (["button", "a", "label", "option"].includes(tag)) {
|
|
543
|
+
element.text = extractTextContent(elementStr, tag);
|
|
544
|
+
}
|
|
545
|
+
element.description = generateDescription(element);
|
|
546
|
+
return element;
|
|
547
|
+
}
|
|
548
|
+
function extractElements(content, filePath, route) {
|
|
549
|
+
const template = extractTemplateContent(content, filePath);
|
|
550
|
+
if (!template.trim()) {
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
const elements = [];
|
|
554
|
+
const elementPattern = /<(\w+)(?:\s+[^>]*)?(?:\/>|>[\s\S]*?<\/\1>)/g;
|
|
555
|
+
const matches = template.matchAll(elementPattern);
|
|
556
|
+
for (const match of matches) {
|
|
557
|
+
const elementStr = match[0];
|
|
558
|
+
const element = parseElement(elementStr, filePath, route);
|
|
559
|
+
if (element) {
|
|
560
|
+
elements.push(element);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return elements;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/generator/sourceScanner.ts
|
|
567
|
+
var DEFAULT_EXTENSIONS = [".vue", ".astro", ".tsx", ".jsx", ".svelte"];
|
|
568
|
+
function fileToRoute(filePath, pagesDir) {
|
|
569
|
+
let route = path4.relative(pagesDir, filePath);
|
|
570
|
+
route = route.replace(/\.(vue|astro|tsx|jsx|svelte)$/, "");
|
|
571
|
+
route = route.replace(/\/index$/, "");
|
|
572
|
+
route = route.replace(/^index$/, "");
|
|
573
|
+
route = route.replace(/\[\.\.\.(\w+)\]/g, "*");
|
|
574
|
+
route = route.replace(/\[(\w+)\]/g, ":$1");
|
|
575
|
+
route = "/" + route;
|
|
576
|
+
route = route.replace(/\/+/g, "/");
|
|
577
|
+
if (route.length > 1) {
|
|
578
|
+
route = route.replace(/\/$/, "");
|
|
579
|
+
}
|
|
580
|
+
return route;
|
|
581
|
+
}
|
|
582
|
+
function getComponentName(filePath) {
|
|
583
|
+
const basename2 = path4.basename(filePath);
|
|
584
|
+
return basename2.replace(/\.(vue|astro|tsx|jsx|svelte)$/, "");
|
|
585
|
+
}
|
|
586
|
+
async function scanDirectory(dir, extensions, cwd) {
|
|
587
|
+
const fullDir = path4.resolve(cwd, dir);
|
|
588
|
+
try {
|
|
589
|
+
await promises.access(fullDir);
|
|
590
|
+
} catch {
|
|
591
|
+
return [];
|
|
592
|
+
}
|
|
593
|
+
const patterns = extensions.map((ext) => `**/*${ext}`);
|
|
594
|
+
const files = [];
|
|
595
|
+
for (const pattern of patterns) {
|
|
596
|
+
const matches = await glob(pattern, {
|
|
597
|
+
cwd: fullDir,
|
|
598
|
+
absolute: true,
|
|
599
|
+
nodir: true
|
|
600
|
+
});
|
|
601
|
+
files.push(...matches);
|
|
602
|
+
}
|
|
603
|
+
return files;
|
|
604
|
+
}
|
|
605
|
+
async function scanProjectSource(config) {
|
|
606
|
+
const cwd = config.cwd ?? process.cwd();
|
|
607
|
+
const extensions = config.extensions ?? DEFAULT_EXTENSIONS;
|
|
608
|
+
const routes = [];
|
|
609
|
+
const components = [];
|
|
610
|
+
const allElements = [];
|
|
611
|
+
if (config.pagesDir) {
|
|
612
|
+
const pageFiles = await scanDirectory(config.pagesDir, extensions, cwd);
|
|
613
|
+
const pagesFullDir = path4.resolve(cwd, config.pagesDir);
|
|
614
|
+
for (const file of pageFiles) {
|
|
615
|
+
const routePath = fileToRoute(file, pagesFullDir);
|
|
616
|
+
const name = getComponentName(file);
|
|
617
|
+
routes.push({
|
|
618
|
+
path: routePath,
|
|
619
|
+
file,
|
|
620
|
+
name
|
|
621
|
+
});
|
|
622
|
+
const content = await promises.readFile(file, "utf-8");
|
|
623
|
+
const elements = extractElements(content, file, routePath);
|
|
624
|
+
components.push({
|
|
625
|
+
name,
|
|
626
|
+
file,
|
|
627
|
+
elements
|
|
628
|
+
});
|
|
629
|
+
allElements.push(...elements);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (config.componentsDir) {
|
|
633
|
+
const componentFiles = await scanDirectory(config.componentsDir, extensions, cwd);
|
|
634
|
+
for (const file of componentFiles) {
|
|
635
|
+
if (components.some((c) => c.file === file)) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const name = getComponentName(file);
|
|
639
|
+
const content = await promises.readFile(file, "utf-8");
|
|
640
|
+
const elements = extractElements(content, file, void 0);
|
|
641
|
+
components.push({
|
|
642
|
+
name,
|
|
643
|
+
file,
|
|
644
|
+
elements
|
|
645
|
+
});
|
|
646
|
+
allElements.push(...elements);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (!config.pagesDir && !config.componentsDir) {
|
|
650
|
+
const commonPageDirs = ["src/pages", "pages", "app", "src/app", "src/routes"];
|
|
651
|
+
const commonComponentDirs = ["src/components", "components", "src/lib", "lib"];
|
|
652
|
+
for (const dir of commonPageDirs) {
|
|
653
|
+
const files = await scanDirectory(dir, extensions, cwd);
|
|
654
|
+
if (files.length > 0) {
|
|
655
|
+
const pagesFullDir = path4.resolve(cwd, dir);
|
|
656
|
+
for (const file of files) {
|
|
657
|
+
const routePath = fileToRoute(file, pagesFullDir);
|
|
658
|
+
const name = getComponentName(file);
|
|
659
|
+
routes.push({
|
|
660
|
+
path: routePath,
|
|
661
|
+
file,
|
|
662
|
+
name
|
|
663
|
+
});
|
|
664
|
+
const content = await promises.readFile(file, "utf-8");
|
|
665
|
+
const elements = extractElements(content, file, routePath);
|
|
666
|
+
components.push({
|
|
667
|
+
name,
|
|
668
|
+
file,
|
|
669
|
+
elements
|
|
670
|
+
});
|
|
671
|
+
allElements.push(...elements);
|
|
672
|
+
}
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
for (const dir of commonComponentDirs) {
|
|
677
|
+
const files = await scanDirectory(dir, extensions, cwd);
|
|
678
|
+
if (files.length > 0) {
|
|
679
|
+
for (const file of files) {
|
|
680
|
+
if (components.some((c) => c.file === file)) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const name = getComponentName(file);
|
|
684
|
+
const content = await promises.readFile(file, "utf-8");
|
|
685
|
+
const elements = extractElements(content, file, void 0);
|
|
686
|
+
components.push({
|
|
687
|
+
name,
|
|
688
|
+
file,
|
|
689
|
+
elements
|
|
690
|
+
});
|
|
691
|
+
allElements.push(...elements);
|
|
692
|
+
}
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
routes,
|
|
699
|
+
components,
|
|
700
|
+
allElements
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
function formatScanResultsForPrompt(result) {
|
|
704
|
+
const lines = [];
|
|
705
|
+
if (result.routes.length > 0) {
|
|
706
|
+
lines.push("## ROUTES");
|
|
707
|
+
lines.push("");
|
|
708
|
+
for (const route of result.routes) {
|
|
709
|
+
lines.push(`- ${route.path}: ${route.name}`);
|
|
710
|
+
}
|
|
711
|
+
lines.push("");
|
|
712
|
+
}
|
|
713
|
+
const elementsByRoute = /* @__PURE__ */ new Map();
|
|
714
|
+
for (const element of result.allElements) {
|
|
715
|
+
const route = element.route ?? "shared";
|
|
716
|
+
if (!elementsByRoute.has(route)) {
|
|
717
|
+
elementsByRoute.set(route, []);
|
|
718
|
+
}
|
|
719
|
+
elementsByRoute.get(route).push(element);
|
|
720
|
+
}
|
|
721
|
+
lines.push("## ELEMENTS");
|
|
722
|
+
lines.push("");
|
|
723
|
+
for (const [route, elements] of elementsByRoute) {
|
|
724
|
+
lines.push(`### ${route === "shared" ? "Shared Components" : route}`);
|
|
725
|
+
lines.push("");
|
|
726
|
+
for (const el of elements) {
|
|
727
|
+
const locators = [];
|
|
728
|
+
if (el.testId) locators.push(`data-testid="${el.testId}"`);
|
|
729
|
+
if (el.text) locators.push(`text="${el.text}"`);
|
|
730
|
+
if (el.role) locators.push(`role="${el.role}"`);
|
|
731
|
+
if (el.name) locators.push(`name="${el.name}"`);
|
|
732
|
+
if (el.placeholder) locators.push(`placeholder="${el.placeholder}"`);
|
|
733
|
+
if (el.type) locators.push(`type="${el.type}"`);
|
|
734
|
+
const locatorStr = locators.length > 0 ? `[${locators.join(", ")}]` : "";
|
|
735
|
+
const description = el.description ? ` - ${el.description}` : "";
|
|
736
|
+
lines.push(`- <${el.tag}>${locatorStr}${description}`);
|
|
737
|
+
}
|
|
738
|
+
lines.push("");
|
|
739
|
+
}
|
|
740
|
+
return lines.join("\n");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/generator/prompts.ts
|
|
744
|
+
var SYSTEM_PROMPT = `You are a test automation expert that converts natural language test descriptions into YAML test definitions.
|
|
745
|
+
|
|
746
|
+
## Schema Structure
|
|
747
|
+
|
|
748
|
+
A test definition must have:
|
|
749
|
+
- name: A descriptive test name (non-empty string)
|
|
750
|
+
- platform: One of 'web', 'android', or 'ios'
|
|
751
|
+
- config: Optional configuration object
|
|
752
|
+
- steps: Array of actions (minimum 1 action required)
|
|
753
|
+
|
|
754
|
+
## Available Actions
|
|
755
|
+
|
|
756
|
+
1. navigate - Navigate to a URL
|
|
757
|
+
{ type: 'navigate', value: string }
|
|
758
|
+
|
|
759
|
+
2. tap - Click or tap an element
|
|
760
|
+
{ type: 'tap', target: Locator }
|
|
761
|
+
|
|
762
|
+
3. input - Type text into an input field
|
|
763
|
+
{ type: 'input', target: Locator, value: string }
|
|
764
|
+
|
|
765
|
+
4. assert - Assert element exists or contains text
|
|
766
|
+
{ type: 'assert', target: Locator, value?: string }
|
|
767
|
+
|
|
768
|
+
5. wait - Wait for an element or timeout
|
|
769
|
+
{ type: 'wait', target?: Locator, timeout?: number }
|
|
770
|
+
Note: Requires either target or timeout
|
|
771
|
+
|
|
772
|
+
6. scroll - Scroll the page or to an element
|
|
773
|
+
{ type: 'scroll', target?: Locator, direction?: 'up'|'down', amount?: number }
|
|
774
|
+
|
|
775
|
+
7. screenshot - Take a screenshot
|
|
776
|
+
{ type: 'screenshot', name?: string }
|
|
777
|
+
|
|
778
|
+
## Locator Structure
|
|
779
|
+
|
|
780
|
+
A locator must have AT LEAST ONE of these properties:
|
|
781
|
+
- description: Human-readable description for AI healing
|
|
782
|
+
- testId: data-testid attribute value
|
|
783
|
+
- text: Text content to match
|
|
784
|
+
- css: CSS selector
|
|
785
|
+
- xpath: XPath expression
|
|
786
|
+
- role: ARIA role attribute
|
|
787
|
+
- name: Accessible name
|
|
788
|
+
|
|
789
|
+
## Configuration Options
|
|
790
|
+
|
|
791
|
+
web:
|
|
792
|
+
baseUrl: Base URL for the application
|
|
793
|
+
browser: Browser to use (e.g., 'chromium', 'firefox', 'webkit')
|
|
794
|
+
headless: Run browser in headless mode (boolean)
|
|
795
|
+
timeout: Default timeout in milliseconds
|
|
796
|
+
|
|
797
|
+
android:
|
|
798
|
+
appId: Android application package ID
|
|
799
|
+
device: Device name or ID
|
|
800
|
+
|
|
801
|
+
ios:
|
|
802
|
+
bundleId: iOS bundle identifier
|
|
803
|
+
simulator: Simulator name
|
|
804
|
+
|
|
805
|
+
## Example 1: Login Test
|
|
806
|
+
|
|
807
|
+
\`\`\`yaml
|
|
808
|
+
name: Login with valid credentials
|
|
809
|
+
platform: web
|
|
810
|
+
config:
|
|
811
|
+
web:
|
|
812
|
+
baseUrl: https://example.com
|
|
813
|
+
headless: true
|
|
814
|
+
steps:
|
|
815
|
+
- type: navigate
|
|
816
|
+
value: /login
|
|
817
|
+
- type: input
|
|
818
|
+
target:
|
|
819
|
+
testId: email-input
|
|
820
|
+
description: Email input field
|
|
821
|
+
value: test@example.com
|
|
822
|
+
- type: input
|
|
823
|
+
target:
|
|
824
|
+
testId: password-input
|
|
825
|
+
description: Password input field
|
|
826
|
+
value: password123
|
|
827
|
+
- type: tap
|
|
828
|
+
target:
|
|
829
|
+
text: Sign In
|
|
830
|
+
role: button
|
|
831
|
+
description: Sign in button
|
|
832
|
+
- type: assert
|
|
833
|
+
target:
|
|
834
|
+
text: Welcome
|
|
835
|
+
description: Welcome message after login
|
|
836
|
+
\`\`\`
|
|
837
|
+
|
|
838
|
+
## Example 2: Search Test
|
|
839
|
+
|
|
840
|
+
\`\`\`yaml
|
|
841
|
+
name: Search for products
|
|
842
|
+
platform: web
|
|
843
|
+
config:
|
|
844
|
+
web:
|
|
845
|
+
baseUrl: https://shop.example.com
|
|
846
|
+
steps:
|
|
847
|
+
- type: navigate
|
|
848
|
+
value: /
|
|
849
|
+
- type: input
|
|
850
|
+
target:
|
|
851
|
+
css: input[type="search"]
|
|
852
|
+
description: Product search input
|
|
853
|
+
value: laptop
|
|
854
|
+
- type: tap
|
|
855
|
+
target:
|
|
856
|
+
role: button
|
|
857
|
+
name: Search
|
|
858
|
+
description: Search button
|
|
859
|
+
- type: wait
|
|
860
|
+
target:
|
|
861
|
+
css: .search-results
|
|
862
|
+
description: Search results container
|
|
863
|
+
timeout: 5000
|
|
864
|
+
- type: assert
|
|
865
|
+
target:
|
|
866
|
+
text: results found
|
|
867
|
+
description: Results count message
|
|
868
|
+
\`\`\`
|
|
869
|
+
|
|
870
|
+
## Example 3: Mobile App Test
|
|
871
|
+
|
|
872
|
+
\`\`\`yaml
|
|
873
|
+
name: Add item to cart
|
|
874
|
+
platform: android
|
|
875
|
+
config:
|
|
876
|
+
android:
|
|
877
|
+
appId: com.example.shop
|
|
878
|
+
steps:
|
|
879
|
+
- type: tap
|
|
880
|
+
target:
|
|
881
|
+
testId: category-electronics
|
|
882
|
+
description: Electronics category button
|
|
883
|
+
- type: scroll
|
|
884
|
+
direction: down
|
|
885
|
+
amount: 300
|
|
886
|
+
- type: tap
|
|
887
|
+
target:
|
|
888
|
+
text: Laptop Pro
|
|
889
|
+
description: Product card for Laptop Pro
|
|
890
|
+
- type: tap
|
|
891
|
+
target:
|
|
892
|
+
testId: add-to-cart-button
|
|
893
|
+
description: Add to cart button
|
|
894
|
+
- type: assert
|
|
895
|
+
target:
|
|
896
|
+
text: Added to cart
|
|
897
|
+
description: Success message
|
|
898
|
+
- type: screenshot
|
|
899
|
+
name: cart-confirmation
|
|
900
|
+
\`\`\`
|
|
901
|
+
|
|
902
|
+
## Important Instructions
|
|
903
|
+
|
|
904
|
+
1. Output ONLY valid YAML - no markdown code blocks, no explanations
|
|
905
|
+
2. Every locator MUST have at least one selector property
|
|
906
|
+
3. Include descriptive locator descriptions for AI healing
|
|
907
|
+
4. Use multiple locator strategies when possible for resilience
|
|
908
|
+
5. For wait actions, provide either a target or timeout (or both)
|
|
909
|
+
6. Use appropriate platform-specific configurations
|
|
910
|
+
7. Ensure all strings are properly quoted if they contain special characters
|
|
911
|
+
8. Action steps must be in logical order
|
|
912
|
+
|
|
913
|
+
Generate the test definition now based on the user's description.`;
|
|
914
|
+
function buildPrompt(naturalLanguage, context) {
|
|
915
|
+
const parts = [
|
|
916
|
+
"Generate a test definition for the following scenario:",
|
|
917
|
+
"",
|
|
918
|
+
naturalLanguage
|
|
919
|
+
];
|
|
920
|
+
if (context) {
|
|
921
|
+
parts.push("", "Additional Context:");
|
|
922
|
+
if (context.platform) {
|
|
923
|
+
parts.push(`- Platform: ${context.platform}`);
|
|
924
|
+
}
|
|
925
|
+
if (context.baseUrl) {
|
|
926
|
+
parts.push(`- Base URL: ${context.baseUrl}`);
|
|
927
|
+
}
|
|
928
|
+
if (context.additionalContext) {
|
|
929
|
+
parts.push(`- ${context.additionalContext}`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
parts.push("", "Output only valid YAML without code block markers.");
|
|
933
|
+
return parts.join("\n");
|
|
934
|
+
}
|
|
935
|
+
function buildSourceAwareSystemPrompt(scanResult) {
|
|
936
|
+
const parts = [
|
|
937
|
+
"You are a test automation expert that converts natural language test descriptions into YAML test definitions.",
|
|
938
|
+
"",
|
|
939
|
+
"## Schema Structure",
|
|
940
|
+
"",
|
|
941
|
+
"A test definition must have:",
|
|
942
|
+
"- name: A descriptive test name (non-empty string)",
|
|
943
|
+
"- platform: One of 'web', 'android', or 'ios'",
|
|
944
|
+
"- config: Optional configuration object",
|
|
945
|
+
"- steps: Array of actions (minimum 1 action required)",
|
|
946
|
+
"",
|
|
947
|
+
"## Available Actions",
|
|
948
|
+
"",
|
|
949
|
+
"1. navigate - Navigate to a URL",
|
|
950
|
+
" { type: 'navigate', value: string }",
|
|
951
|
+
"",
|
|
952
|
+
"2. tap - Click or tap an element",
|
|
953
|
+
" { type: 'tap', target: Locator }",
|
|
954
|
+
"",
|
|
955
|
+
"3. input - Type text into an input field",
|
|
956
|
+
" { type: 'input', target: Locator, value: string }",
|
|
957
|
+
"",
|
|
958
|
+
"4. assert - Assert element exists or contains text",
|
|
959
|
+
" { type: 'assert', target: Locator, value?: string }",
|
|
960
|
+
"",
|
|
961
|
+
"5. wait - Wait for an element or timeout",
|
|
962
|
+
" { type: 'wait', target?: Locator, timeout?: number }",
|
|
963
|
+
" Note: Requires either target or timeout",
|
|
964
|
+
"",
|
|
965
|
+
"6. scroll - Scroll the page or to an element",
|
|
966
|
+
" { type: 'scroll', target?: Locator, direction?: 'up'|'down', amount?: number }",
|
|
967
|
+
"",
|
|
968
|
+
"7. screenshot - Take a screenshot",
|
|
969
|
+
" { type: 'screenshot', name?: string }",
|
|
970
|
+
"",
|
|
971
|
+
"## Locator Structure",
|
|
972
|
+
"",
|
|
973
|
+
"A locator must have AT LEAST ONE of these properties:",
|
|
974
|
+
"- description: Human-readable description for AI healing",
|
|
975
|
+
"- testId: data-testid attribute value",
|
|
976
|
+
"- text: Text content to match",
|
|
977
|
+
"- css: CSS selector",
|
|
978
|
+
"- xpath: XPath expression",
|
|
979
|
+
"- role: ARIA role attribute",
|
|
980
|
+
"- name: Accessible name",
|
|
981
|
+
"",
|
|
982
|
+
"## PROJECT STRUCTURE",
|
|
983
|
+
"",
|
|
984
|
+
"The following routes and elements were extracted from the project source code.",
|
|
985
|
+
"Use these REAL selectors in your generated tests.",
|
|
986
|
+
"",
|
|
987
|
+
"### Selector Priority (prefer earlier options):",
|
|
988
|
+
"1. text - Most resilient to DOM changes",
|
|
989
|
+
"2. role + name - ARIA-compliant, accessible",
|
|
990
|
+
"3. testId - Explicit but requires dev setup",
|
|
991
|
+
"4. css - Last resort, fragile",
|
|
992
|
+
""
|
|
993
|
+
];
|
|
994
|
+
parts.push(formatScanResultsForPrompt(scanResult));
|
|
995
|
+
parts.push(
|
|
996
|
+
"## Configuration Options",
|
|
997
|
+
"",
|
|
998
|
+
"web:",
|
|
999
|
+
" baseUrl: Base URL for the application",
|
|
1000
|
+
" browser: Browser to use (e.g., 'chromium', 'firefox', 'webkit')",
|
|
1001
|
+
" headless: Run browser in headless mode (boolean)",
|
|
1002
|
+
" timeout: Default timeout in milliseconds",
|
|
1003
|
+
"",
|
|
1004
|
+
"android:",
|
|
1005
|
+
" appId: Android application package ID",
|
|
1006
|
+
" device: Device name or ID",
|
|
1007
|
+
"",
|
|
1008
|
+
"ios:",
|
|
1009
|
+
" bundleId: iOS bundle identifier",
|
|
1010
|
+
" simulator: Simulator name",
|
|
1011
|
+
"",
|
|
1012
|
+
"## Example Test Structure",
|
|
1013
|
+
"",
|
|
1014
|
+
"```yaml",
|
|
1015
|
+
"name: Example test name",
|
|
1016
|
+
"platform: web",
|
|
1017
|
+
"config:",
|
|
1018
|
+
" web:",
|
|
1019
|
+
" baseUrl: https://example.com",
|
|
1020
|
+
" headless: true",
|
|
1021
|
+
"steps:",
|
|
1022
|
+
" - type: navigate",
|
|
1023
|
+
" value: /login",
|
|
1024
|
+
" - type: input",
|
|
1025
|
+
" target:",
|
|
1026
|
+
" text: Email",
|
|
1027
|
+
" description: Email input field",
|
|
1028
|
+
" value: test@example.com",
|
|
1029
|
+
" - type: input",
|
|
1030
|
+
" target:",
|
|
1031
|
+
" role: textbox",
|
|
1032
|
+
" name: Password",
|
|
1033
|
+
" description: Password input field",
|
|
1034
|
+
" value: password123",
|
|
1035
|
+
" - type: tap",
|
|
1036
|
+
" target:",
|
|
1037
|
+
" text: Sign In",
|
|
1038
|
+
" role: button",
|
|
1039
|
+
" description: Sign in button",
|
|
1040
|
+
" - type: assert",
|
|
1041
|
+
" target:",
|
|
1042
|
+
" text: Welcome",
|
|
1043
|
+
" description: Welcome message after login",
|
|
1044
|
+
"```",
|
|
1045
|
+
"",
|
|
1046
|
+
"## Important Instructions",
|
|
1047
|
+
"",
|
|
1048
|
+
"1. Output ONLY valid YAML - no markdown code blocks, no explanations",
|
|
1049
|
+
"2. Use REAL selectors from the project structure above whenever possible",
|
|
1050
|
+
"3. Every locator MUST have at least one selector property",
|
|
1051
|
+
"4. Include descriptive locator descriptions for AI healing",
|
|
1052
|
+
"5. Prefer text and role selectors over testId and css for resilience",
|
|
1053
|
+
"6. Use multiple locator strategies when possible for resilience",
|
|
1054
|
+
"7. For wait actions, provide either a target or timeout (or both)",
|
|
1055
|
+
"8. Use appropriate platform-specific configurations",
|
|
1056
|
+
"9. Ensure all strings are properly quoted if they contain special characters",
|
|
1057
|
+
"10. Action steps must be in logical order",
|
|
1058
|
+
"",
|
|
1059
|
+
"Generate the test definition now based on the user's description."
|
|
1060
|
+
);
|
|
1061
|
+
return parts.join("\n");
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/generator/testGenerator.ts
|
|
1065
|
+
function cleanYamlResponse(response) {
|
|
1066
|
+
let cleaned = response.replace(/```ya?ml\n?/gi, "").replace(/```\n?/g, "");
|
|
1067
|
+
cleaned = cleaned.trim();
|
|
1068
|
+
return cleaned;
|
|
1069
|
+
}
|
|
1070
|
+
async function generateTest(naturalLanguage, options) {
|
|
1071
|
+
const provider = createAIProvider(options.aiConfig);
|
|
1072
|
+
let systemPrompt = SYSTEM_PROMPT;
|
|
1073
|
+
if (options.source !== null) {
|
|
1074
|
+
const sourceConfig = options.source ?? {};
|
|
1075
|
+
const scanResult = await scanProjectSource(sourceConfig);
|
|
1076
|
+
if (scanResult.allElements.length > 0) {
|
|
1077
|
+
systemPrompt = buildSourceAwareSystemPrompt(scanResult);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
const context = {
|
|
1081
|
+
baseUrl: options.baseUrl,
|
|
1082
|
+
platform: options.platform,
|
|
1083
|
+
additionalContext: options.additionalContext
|
|
1084
|
+
};
|
|
1085
|
+
const userPrompt = buildPrompt(naturalLanguage, context);
|
|
1086
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
1087
|
+
let lastError;
|
|
1088
|
+
let lastYaml;
|
|
1089
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1090
|
+
try {
|
|
1091
|
+
let promptWithFeedback = userPrompt;
|
|
1092
|
+
if (attempt > 0 && lastError) {
|
|
1093
|
+
promptWithFeedback = `${userPrompt}
|
|
1094
|
+
|
|
1095
|
+
Previous attempt failed with error: ${lastError.message}
|
|
1096
|
+
|
|
1097
|
+
Please fix the issue and generate valid YAML.`;
|
|
1098
|
+
}
|
|
1099
|
+
const response = await provider.generateCompletion(promptWithFeedback, systemPrompt);
|
|
1100
|
+
const yaml = cleanYamlResponse(response);
|
|
1101
|
+
lastYaml = yaml;
|
|
1102
|
+
const parsed = parse(yaml);
|
|
1103
|
+
const validated = TestDefinitionSchema.parse(parsed);
|
|
1104
|
+
return {
|
|
1105
|
+
success: true,
|
|
1106
|
+
test: validated,
|
|
1107
|
+
yaml,
|
|
1108
|
+
attempts: attempt + 1
|
|
1109
|
+
};
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
1112
|
+
if (attempt === maxRetries - 1) {
|
|
1113
|
+
return {
|
|
1114
|
+
success: false,
|
|
1115
|
+
error: `Failed to generate valid test after ${maxRetries} attempts. Last error: ${lastError.message}`,
|
|
1116
|
+
yaml: lastYaml,
|
|
1117
|
+
attempts: maxRetries
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return {
|
|
1123
|
+
success: false,
|
|
1124
|
+
error: "Unknown error occurred during test generation",
|
|
1125
|
+
attempts: maxRetries
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function displayMissingEnvVars(missing) {
|
|
1129
|
+
console.log("\n\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
|
|
1130
|
+
console.log("\u2502 \u26A0\uFE0F Missing Environment Variables \u2502");
|
|
1131
|
+
console.log("\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524");
|
|
1132
|
+
for (const name of missing) {
|
|
1133
|
+
console.log(`\u2502 \u2022 ${name.padEnd(39)}\u2502`);
|
|
1134
|
+
}
|
|
1135
|
+
console.log("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n");
|
|
1136
|
+
}
|
|
1137
|
+
async function promptAddToEnv(missing, envPath) {
|
|
1138
|
+
const { shouldAdd } = await prompts({
|
|
1139
|
+
type: "confirm",
|
|
1140
|
+
name: "shouldAdd",
|
|
1141
|
+
message: `Add missing variables to ${path4__default.basename(envPath)}?`,
|
|
1142
|
+
initial: true
|
|
1143
|
+
});
|
|
1144
|
+
if (!shouldAdd) return false;
|
|
1145
|
+
const values = {};
|
|
1146
|
+
for (const name of missing) {
|
|
1147
|
+
const { value } = await prompts({
|
|
1148
|
+
type: "password",
|
|
1149
|
+
// Hide sensitive values
|
|
1150
|
+
name: "value",
|
|
1151
|
+
message: `Enter value for ${name}:`
|
|
1152
|
+
});
|
|
1153
|
+
if (value !== void 0) {
|
|
1154
|
+
values[name] = value;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
const lines = Object.entries(values).map(([key, val]) => `${key}=${val}`).join("\n");
|
|
1158
|
+
let existingContent = "";
|
|
1159
|
+
try {
|
|
1160
|
+
existingContent = await fs3.readFile(envPath, "utf8");
|
|
1161
|
+
} catch {
|
|
1162
|
+
}
|
|
1163
|
+
const newContent = existingContent ? `${existingContent.trimEnd()}
|
|
1164
|
+
${lines}
|
|
1165
|
+
` : `${lines}
|
|
1166
|
+
`;
|
|
1167
|
+
await fs3.writeFile(envPath, newContent, "utf8");
|
|
1168
|
+
console.log(`
|
|
1169
|
+
\u2713 Added ${missing.length} variable(s) to ${path4__default.basename(envPath)}
|
|
1170
|
+
`);
|
|
1171
|
+
dotenv2.config({ path: envPath, override: true });
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
async function validateEnvVars(missing, projectDir) {
|
|
1175
|
+
if (missing.length === 0) return true;
|
|
1176
|
+
displayMissingEnvVars(missing);
|
|
1177
|
+
const envPath = path4__default.join(projectDir, ".env");
|
|
1178
|
+
const added = await promptAddToEnv(missing, envPath);
|
|
1179
|
+
if (!added) {
|
|
1180
|
+
console.log("Cannot continue without required environment variables.");
|
|
1181
|
+
return false;
|
|
1182
|
+
}
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/cli/index.ts
|
|
1187
|
+
dotenv2.config();
|
|
1188
|
+
var CONFIG_FILENAME = "intellitester.config.yaml";
|
|
1189
|
+
var BROWSER_ALIASES = {
|
|
1190
|
+
chrome: "chromium",
|
|
1191
|
+
chromium: "chromium",
|
|
1192
|
+
safari: "webkit",
|
|
1193
|
+
webkit: "webkit",
|
|
1194
|
+
firefox: "firefox",
|
|
1195
|
+
ff: "firefox"
|
|
1196
|
+
};
|
|
1197
|
+
var resolveBrowserName = (input) => {
|
|
1198
|
+
const normalized = input.toLowerCase().trim();
|
|
1199
|
+
const resolved = BROWSER_ALIASES[normalized];
|
|
1200
|
+
if (!resolved) {
|
|
1201
|
+
const valid = Object.keys(BROWSER_ALIASES).join(", ");
|
|
1202
|
+
throw new Error(`Unknown browser "${input}". Valid options: ${valid}`);
|
|
1203
|
+
}
|
|
1204
|
+
return resolved;
|
|
1205
|
+
};
|
|
1206
|
+
var detectPackageManager = async () => {
|
|
1207
|
+
if (await fileExists("pnpm-lock.yaml")) return "pnpm";
|
|
1208
|
+
if (await fileExists("bun.lockb")) return "bun";
|
|
1209
|
+
if (await fileExists("yarn.lock")) return "yarn";
|
|
1210
|
+
return "npm";
|
|
1211
|
+
};
|
|
1212
|
+
var execCommand = async (cmd, args, cwd) => {
|
|
1213
|
+
return new Promise((resolve2, reject) => {
|
|
1214
|
+
console.log(`Running: ${cmd} ${args.join(" ")}`);
|
|
1215
|
+
const child = spawn(cmd, args, {
|
|
1216
|
+
cwd,
|
|
1217
|
+
stdio: "inherit",
|
|
1218
|
+
shell: true
|
|
1219
|
+
});
|
|
1220
|
+
child.on("close", (code) => {
|
|
1221
|
+
if (code === 0) resolve2();
|
|
1222
|
+
else reject(new Error(`Command failed with exit code ${code}`));
|
|
1223
|
+
});
|
|
1224
|
+
child.on("error", reject);
|
|
1225
|
+
});
|
|
1226
|
+
};
|
|
1227
|
+
var buildAndPreview = async (config, cwd) => {
|
|
1228
|
+
const pm = await detectPackageManager();
|
|
1229
|
+
const previewConfig = config?.preview || {};
|
|
1230
|
+
const buildCmd = previewConfig.build?.command || `${pm} run build`;
|
|
1231
|
+
const [buildExec, ...buildArgs] = buildCmd.split(" ");
|
|
1232
|
+
const previewCmd = previewConfig.preview?.command || `${pm} run preview`;
|
|
1233
|
+
const [previewExec, ...previewArgs] = previewCmd.split(" ");
|
|
1234
|
+
const previewUrl = previewConfig.url || config?.webServer?.url || config?.platforms?.web?.baseUrl || "http://localhost:4321";
|
|
1235
|
+
const timeout = previewConfig.timeout || 6e4;
|
|
1236
|
+
console.log("\n\u{1F4E6} Building project...\n");
|
|
1237
|
+
await execCommand(buildExec, buildArgs, cwd);
|
|
1238
|
+
console.log("\n\u2705 Build complete\n");
|
|
1239
|
+
console.log("\n\u{1F680} Starting preview server...\n");
|
|
1240
|
+
const previewProcess = await startPreviewServer(previewExec, previewArgs, cwd, previewUrl, timeout);
|
|
1241
|
+
const cleanup = () => {
|
|
1242
|
+
if (previewProcess && !previewProcess.killed) {
|
|
1243
|
+
console.log("\n\u{1F6D1} Stopping preview server...");
|
|
1244
|
+
previewProcess.kill("SIGTERM");
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
process2.on("SIGINT", cleanup);
|
|
1248
|
+
process2.on("SIGTERM", cleanup);
|
|
1249
|
+
return { previewProcess, cleanup };
|
|
1250
|
+
};
|
|
1251
|
+
var startPreviewServer = async (cmd, args, cwd, url, timeout = 6e4) => {
|
|
1252
|
+
return new Promise((resolve2, reject) => {
|
|
1253
|
+
console.log(`Starting preview server: ${cmd} ${args.join(" ")}`);
|
|
1254
|
+
const child = spawn(cmd, args, {
|
|
1255
|
+
cwd,
|
|
1256
|
+
stdio: "pipe",
|
|
1257
|
+
shell: true
|
|
1258
|
+
});
|
|
1259
|
+
let output = "";
|
|
1260
|
+
const startTime = Date.now();
|
|
1261
|
+
const checkServer = async () => {
|
|
1262
|
+
try {
|
|
1263
|
+
const response = await fetch(url, { method: "HEAD" });
|
|
1264
|
+
if (response.ok || response.status < 500) {
|
|
1265
|
+
console.log(`Preview server ready at ${url}`);
|
|
1266
|
+
resolve2(child);
|
|
1267
|
+
return true;
|
|
1268
|
+
}
|
|
1269
|
+
} catch {
|
|
1270
|
+
}
|
|
1271
|
+
return false;
|
|
1272
|
+
};
|
|
1273
|
+
const pollInterval = setInterval(async () => {
|
|
1274
|
+
if (await checkServer()) {
|
|
1275
|
+
clearInterval(pollInterval);
|
|
1276
|
+
} else if (Date.now() - startTime > timeout) {
|
|
1277
|
+
clearInterval(pollInterval);
|
|
1278
|
+
child.kill();
|
|
1279
|
+
reject(new Error(`Preview server failed to start within ${timeout}ms`));
|
|
1280
|
+
}
|
|
1281
|
+
}, 500);
|
|
1282
|
+
child.stdout?.on("data", (data) => {
|
|
1283
|
+
output += data.toString();
|
|
1284
|
+
process2.stdout.write(data);
|
|
1285
|
+
});
|
|
1286
|
+
child.stderr?.on("data", (data) => {
|
|
1287
|
+
output += data.toString();
|
|
1288
|
+
process2.stderr.write(data);
|
|
1289
|
+
});
|
|
1290
|
+
child.on("error", (err) => {
|
|
1291
|
+
clearInterval(pollInterval);
|
|
1292
|
+
reject(err);
|
|
1293
|
+
});
|
|
1294
|
+
child.on("close", (code) => {
|
|
1295
|
+
clearInterval(pollInterval);
|
|
1296
|
+
if (code !== 0 && code !== null) {
|
|
1297
|
+
reject(new Error(`Preview server exited with code ${code}
|
|
1298
|
+
${output}`));
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
};
|
|
1303
|
+
var logError = (message) => {
|
|
1304
|
+
console.error(`Error: ${message}`);
|
|
1305
|
+
};
|
|
1306
|
+
var findProjectRoot = async (startPath) => {
|
|
1307
|
+
let currentDir = path4__default.isAbsolute(startPath) ? startPath : path4__default.resolve(startPath);
|
|
1308
|
+
try {
|
|
1309
|
+
const stat = await fs3.stat(currentDir);
|
|
1310
|
+
if (stat.isFile()) {
|
|
1311
|
+
currentDir = path4__default.dirname(currentDir);
|
|
1312
|
+
}
|
|
1313
|
+
} catch {
|
|
1314
|
+
currentDir = path4__default.dirname(currentDir);
|
|
1315
|
+
}
|
|
1316
|
+
const root = path4__default.parse(currentDir).root;
|
|
1317
|
+
while (currentDir !== root) {
|
|
1318
|
+
const markers = ["package.json", ".git", CONFIG_FILENAME];
|
|
1319
|
+
for (const marker of markers) {
|
|
1320
|
+
if (await fileExists(path4__default.join(currentDir, marker))) {
|
|
1321
|
+
return currentDir;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
const parentDir = path4__default.dirname(currentDir);
|
|
1325
|
+
if (parentDir === currentDir) break;
|
|
1326
|
+
currentDir = parentDir;
|
|
1327
|
+
}
|
|
1328
|
+
return null;
|
|
1329
|
+
};
|
|
1330
|
+
var loadProjectEnv = async (targetPath) => {
|
|
1331
|
+
const projectRoot = await findProjectRoot(targetPath);
|
|
1332
|
+
if (projectRoot) {
|
|
1333
|
+
const envPath = path4__default.join(projectRoot, ".env");
|
|
1334
|
+
if (await fileExists(envPath)) {
|
|
1335
|
+
dotenv2.config({ path: envPath, override: false });
|
|
1336
|
+
console.log(`Loaded .env from ${projectRoot}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
var fileExists = async (filePath) => {
|
|
1341
|
+
try {
|
|
1342
|
+
await fs3.access(filePath);
|
|
1343
|
+
return true;
|
|
1344
|
+
} catch {
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
var collectYamlFiles = async (target) => {
|
|
1349
|
+
const stat = await fs3.stat(target);
|
|
1350
|
+
if (stat.isFile()) return [target];
|
|
1351
|
+
if (!stat.isDirectory()) {
|
|
1352
|
+
throw new Error(`Unsupported target: ${target}`);
|
|
1353
|
+
}
|
|
1354
|
+
const entries = await fs3.readdir(target, { withFileTypes: true });
|
|
1355
|
+
const files = [];
|
|
1356
|
+
for (const entry of entries) {
|
|
1357
|
+
const fullPath = path4__default.join(target, entry.name);
|
|
1358
|
+
if (entry.isDirectory()) {
|
|
1359
|
+
const nested = await collectYamlFiles(fullPath);
|
|
1360
|
+
files.push(...nested);
|
|
1361
|
+
} else if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
|
|
1362
|
+
files.push(fullPath);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return files;
|
|
1366
|
+
};
|
|
1367
|
+
var discoverTestFiles = async (testsDir = "tests") => {
|
|
1368
|
+
const absoluteDir = path4__default.resolve(testsDir);
|
|
1369
|
+
if (!await fileExists(absoluteDir)) {
|
|
1370
|
+
return { pipelines: [], workflows: [], tests: [] };
|
|
1371
|
+
}
|
|
1372
|
+
const allFiles = await collectYamlFiles(absoluteDir);
|
|
1373
|
+
const pipelines = [];
|
|
1374
|
+
const workflows = [];
|
|
1375
|
+
const tests = [];
|
|
1376
|
+
for (const file of allFiles) {
|
|
1377
|
+
const name = path4__default.basename(file).toLowerCase();
|
|
1378
|
+
if (name.endsWith(".pipeline.yaml") || name.endsWith(".pipeline.yml")) {
|
|
1379
|
+
pipelines.push(file);
|
|
1380
|
+
} else if (name.endsWith(".workflow.yaml") || name.endsWith(".workflow.yml")) {
|
|
1381
|
+
workflows.push(file);
|
|
1382
|
+
} else if (name.endsWith(".test.yaml") || name.endsWith(".test.yml")) {
|
|
1383
|
+
tests.push(file);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return { pipelines, workflows, tests };
|
|
1387
|
+
};
|
|
1388
|
+
var writeFileIfMissing = async (filePath, contents) => {
|
|
1389
|
+
if (await fileExists(filePath)) return;
|
|
1390
|
+
await fs3.mkdir(path4__default.dirname(filePath), { recursive: true });
|
|
1391
|
+
await fs3.writeFile(filePath, contents, "utf8");
|
|
1392
|
+
};
|
|
1393
|
+
var initCommand = async () => {
|
|
1394
|
+
const configTemplate = `defaults:
|
|
1395
|
+
timeout: 30000
|
|
1396
|
+
screenshots: on-failure
|
|
1397
|
+
|
|
1398
|
+
platforms:
|
|
1399
|
+
web:
|
|
1400
|
+
baseUrl: http://localhost:3000
|
|
1401
|
+
headless: true
|
|
1402
|
+
|
|
1403
|
+
ai:
|
|
1404
|
+
provider: anthropic
|
|
1405
|
+
model: claude-3-5-sonnet-20241022
|
|
1406
|
+
apiKey: \${ANTHROPIC_API_KEY}
|
|
1407
|
+
temperature: 0
|
|
1408
|
+
maxTokens: 4096
|
|
1409
|
+
|
|
1410
|
+
email:
|
|
1411
|
+
provider: inbucket
|
|
1412
|
+
endpoint: http://localhost:9000
|
|
1413
|
+
|
|
1414
|
+
appwrite:
|
|
1415
|
+
endpoint: https://cloud.appwrite.io/v1
|
|
1416
|
+
projectId: your-project-id
|
|
1417
|
+
apiKey: your-api-key
|
|
1418
|
+
`;
|
|
1419
|
+
const sampleTest = `name: Example web smoke test
|
|
1420
|
+
platform: web
|
|
1421
|
+
config:
|
|
1422
|
+
web:
|
|
1423
|
+
baseUrl: http://localhost:3000
|
|
1424
|
+
|
|
1425
|
+
steps:
|
|
1426
|
+
- type: navigate
|
|
1427
|
+
value: /
|
|
1428
|
+
|
|
1429
|
+
- type: assert
|
|
1430
|
+
target:
|
|
1431
|
+
text: "Welcome"
|
|
1432
|
+
`;
|
|
1433
|
+
await writeFileIfMissing(path4__default.resolve(CONFIG_FILENAME), configTemplate);
|
|
1434
|
+
await writeFileIfMissing(path4__default.resolve("tests", "example.web.test.yaml"), sampleTest);
|
|
1435
|
+
console.log("Initialized intellitester.config.yaml and tests/example.web.test.yaml");
|
|
1436
|
+
};
|
|
1437
|
+
var validateCommand = async (target) => {
|
|
1438
|
+
const absoluteTarget = path4__default.resolve(target);
|
|
1439
|
+
const files = await collectYamlFiles(absoluteTarget);
|
|
1440
|
+
if (files.length === 0) {
|
|
1441
|
+
throw new Error(`No YAML files found at ${absoluteTarget}`);
|
|
1442
|
+
}
|
|
1443
|
+
for (const file of files) {
|
|
1444
|
+
await loadTestDefinition(file);
|
|
1445
|
+
console.log(`\u2713 ${path4__default.relative(process2.cwd(), file)} valid`);
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
var resolveBaseUrl = (test, configBaseUrl) => test.config?.web?.baseUrl ?? configBaseUrl;
|
|
1449
|
+
var runTestCommand = async (target, options) => {
|
|
1450
|
+
const absoluteTarget = path4__default.resolve(target);
|
|
1451
|
+
await loadProjectEnv(absoluteTarget);
|
|
1452
|
+
const { parse: parse2 } = await import('yaml');
|
|
1453
|
+
const testContent = await fs3.readFile(absoluteTarget, "utf8");
|
|
1454
|
+
const parsedTest = parse2(testContent);
|
|
1455
|
+
const hasConfigFile = await fileExists(CONFIG_FILENAME);
|
|
1456
|
+
let parsedConfig = void 0;
|
|
1457
|
+
if (hasConfigFile) {
|
|
1458
|
+
const configContent = await fs3.readFile(CONFIG_FILENAME, "utf8");
|
|
1459
|
+
parsedConfig = parse2(configContent);
|
|
1460
|
+
}
|
|
1461
|
+
const configMissing = parsedConfig ? collectMissingEnvVars(parsedConfig) : [];
|
|
1462
|
+
const testMissing = collectMissingEnvVars(parsedTest);
|
|
1463
|
+
const allMissing = [.../* @__PURE__ */ new Set([...configMissing, ...testMissing])];
|
|
1464
|
+
if (allMissing.length > 0) {
|
|
1465
|
+
const projectRoot = await findProjectRoot(absoluteTarget);
|
|
1466
|
+
const canContinue = await validateEnvVars(allMissing, projectRoot || process2.cwd());
|
|
1467
|
+
if (!canContinue) {
|
|
1468
|
+
process2.exit(1);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
const test = await loadTestDefinition(absoluteTarget);
|
|
1472
|
+
const config = hasConfigFile ? await loadIntellitesterConfig(CONFIG_FILENAME) : void 0;
|
|
1473
|
+
const baseUrl = resolveBaseUrl(test, config?.platforms?.web?.baseUrl);
|
|
1474
|
+
const headed = options.headed ?? false;
|
|
1475
|
+
const browser = options.browser ?? "chromium";
|
|
1476
|
+
const skipWebServer = options.noServer ?? false;
|
|
1477
|
+
const debug = options.debug ?? false;
|
|
1478
|
+
const interactive = options.interactive ?? false;
|
|
1479
|
+
const modeFlags = [];
|
|
1480
|
+
if (headed) modeFlags.push("headed");
|
|
1481
|
+
if (debug) modeFlags.push("debug mode");
|
|
1482
|
+
if (interactive) modeFlags.push("interactive");
|
|
1483
|
+
console.log(
|
|
1484
|
+
`Running ${path4__default.basename(absoluteTarget)} on web (${browser}${modeFlags.length > 0 ? ", " + modeFlags.join(", ") : ""})`
|
|
1485
|
+
);
|
|
1486
|
+
const result = await runWebTest(test, {
|
|
1487
|
+
baseUrl,
|
|
1488
|
+
headed,
|
|
1489
|
+
browser,
|
|
1490
|
+
defaultTimeoutMs: config?.defaults?.timeout,
|
|
1491
|
+
webServer: !skipWebServer && config?.webServer ? config.webServer : void 0,
|
|
1492
|
+
debug,
|
|
1493
|
+
interactive,
|
|
1494
|
+
aiConfig: interactive ? config?.ai : void 0
|
|
1495
|
+
});
|
|
1496
|
+
for (const step of result.steps) {
|
|
1497
|
+
const label = `[${step.status === "passed" ? "OK" : "FAIL"}] ${step.action.type}`;
|
|
1498
|
+
if (step.error) {
|
|
1499
|
+
console.error(`${label} - ${step.error}`);
|
|
1500
|
+
} else {
|
|
1501
|
+
console.log(label);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
if (result.status === "failed") {
|
|
1505
|
+
process2.exitCode = 1;
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
var generateCommand = async (prompt, options) => {
|
|
1509
|
+
const targetPath = options.output ? path4__default.resolve(options.output) : process2.cwd();
|
|
1510
|
+
await loadProjectEnv(targetPath);
|
|
1511
|
+
const hasConfigFile = await fileExists(CONFIG_FILENAME);
|
|
1512
|
+
if (!hasConfigFile) {
|
|
1513
|
+
throw new Error('No intellitester.config.yaml found. Run "intellitester init" first and configure AI settings.');
|
|
1514
|
+
}
|
|
1515
|
+
const { parse: parse2 } = await import('yaml');
|
|
1516
|
+
const configContent = await fs3.readFile(CONFIG_FILENAME, "utf8");
|
|
1517
|
+
const parsedConfig = parse2(configContent);
|
|
1518
|
+
const configMissing = collectMissingEnvVars(parsedConfig);
|
|
1519
|
+
if (configMissing.length > 0) {
|
|
1520
|
+
const projectRoot = await findProjectRoot(CONFIG_FILENAME);
|
|
1521
|
+
const canContinue = await validateEnvVars(configMissing, projectRoot || process2.cwd());
|
|
1522
|
+
if (!canContinue) {
|
|
1523
|
+
process2.exit(1);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
const config = await loadIntellitesterConfig(CONFIG_FILENAME);
|
|
1527
|
+
if (!config.ai) {
|
|
1528
|
+
throw new Error('AI configuration missing in intellitester.config.yaml. Add "ai:" section with provider, model, and apiKey.');
|
|
1529
|
+
}
|
|
1530
|
+
const source = options.noSource ? null : options.pagesDir || options.componentsDir ? {
|
|
1531
|
+
pagesDir: options.pagesDir,
|
|
1532
|
+
componentsDir: options.componentsDir
|
|
1533
|
+
} : void 0;
|
|
1534
|
+
const generateOptions = {
|
|
1535
|
+
aiConfig: config.ai,
|
|
1536
|
+
baseUrl: options.baseUrl,
|
|
1537
|
+
platform: options.platform,
|
|
1538
|
+
source
|
|
1539
|
+
};
|
|
1540
|
+
console.log("Generating test...");
|
|
1541
|
+
const result = await generateTest(prompt, generateOptions);
|
|
1542
|
+
if (!result.success) {
|
|
1543
|
+
throw new Error(result.error || "Failed to generate test");
|
|
1544
|
+
}
|
|
1545
|
+
if (options.output) {
|
|
1546
|
+
await fs3.mkdir(path4__default.dirname(options.output), { recursive: true });
|
|
1547
|
+
await fs3.writeFile(options.output, result.yaml, "utf8");
|
|
1548
|
+
console.log(`\u2713 Test saved to ${options.output}`);
|
|
1549
|
+
} else {
|
|
1550
|
+
console.log("\n--- Generated Test ---\n");
|
|
1551
|
+
console.log(result.yaml);
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
var runWorkflowCommand = async (file, options) => {
|
|
1555
|
+
const workflowPath = path4__default.resolve(file);
|
|
1556
|
+
if (!await fileExists(workflowPath)) {
|
|
1557
|
+
logError(`Workflow file not found: ${file}`);
|
|
1558
|
+
process2.exit(1);
|
|
1559
|
+
}
|
|
1560
|
+
await loadProjectEnv(workflowPath);
|
|
1561
|
+
console.log(`Running workflow: ${file}`);
|
|
1562
|
+
const { parse: parse2 } = await import('yaml');
|
|
1563
|
+
const workflowContent = await fs3.readFile(workflowPath, "utf8");
|
|
1564
|
+
const parsedWorkflow = parse2(workflowContent);
|
|
1565
|
+
const hasConfigFile = await fileExists(CONFIG_FILENAME);
|
|
1566
|
+
let parsedConfig = void 0;
|
|
1567
|
+
if (hasConfigFile) {
|
|
1568
|
+
const configContent = await fs3.readFile(CONFIG_FILENAME, "utf8");
|
|
1569
|
+
parsedConfig = parse2(configContent);
|
|
1570
|
+
}
|
|
1571
|
+
const configMissing = parsedConfig ? collectMissingEnvVars(parsedConfig) : [];
|
|
1572
|
+
const workflowMissing = collectMissingEnvVars(parsedWorkflow);
|
|
1573
|
+
const allMissing = [.../* @__PURE__ */ new Set([...configMissing, ...workflowMissing])];
|
|
1574
|
+
if (allMissing.length > 0) {
|
|
1575
|
+
const projectRoot = await findProjectRoot(workflowPath);
|
|
1576
|
+
const canContinue = await validateEnvVars(allMissing, projectRoot || process2.cwd());
|
|
1577
|
+
if (!canContinue) {
|
|
1578
|
+
process2.exit(1);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
const workflow = await loadWorkflowDefinition(workflowPath);
|
|
1582
|
+
const config = hasConfigFile ? await loadIntellitesterConfig(CONFIG_FILENAME) : void 0;
|
|
1583
|
+
const result = await runWorkflow(workflow, workflowPath, {
|
|
1584
|
+
headed: options.visible,
|
|
1585
|
+
browser: options.browser,
|
|
1586
|
+
interactive: options.interactive,
|
|
1587
|
+
debug: options.debug,
|
|
1588
|
+
aiConfig: config?.ai
|
|
1589
|
+
});
|
|
1590
|
+
console.log(`
|
|
1591
|
+
Workflow: ${workflow.name}`);
|
|
1592
|
+
console.log(`Session ID: ${result.sessionId}`);
|
|
1593
|
+
console.log(`Status: ${result.status}
|
|
1594
|
+
`);
|
|
1595
|
+
for (const test of result.tests) {
|
|
1596
|
+
const icon = test.status === "passed" ? "\u2713" : test.status === "failed" ? "\u2717" : "\u25CB";
|
|
1597
|
+
console.log(` ${icon} ${test.file} (${test.status})`);
|
|
1598
|
+
if (test.error) {
|
|
1599
|
+
console.log(` Error: ${test.error}`);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
if (result.cleanupResult) {
|
|
1603
|
+
console.log(`
|
|
1604
|
+
Cleanup: ${result.cleanupResult.deleted.length} resources deleted`);
|
|
1605
|
+
if (result.cleanupResult.failed.length > 0) {
|
|
1606
|
+
console.log(` Failed to delete: ${result.cleanupResult.failed.join(", ")}`);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
process2.exit(result.status === "passed" ? 0 : 1);
|
|
1610
|
+
};
|
|
1611
|
+
var runPipelineCommand = async (file, options) => {
|
|
1612
|
+
const pipelinePath = path4__default.resolve(file);
|
|
1613
|
+
if (!await fileExists(pipelinePath)) {
|
|
1614
|
+
logError(`Pipeline file not found: ${file}`);
|
|
1615
|
+
process2.exit(1);
|
|
1616
|
+
}
|
|
1617
|
+
await loadProjectEnv(pipelinePath);
|
|
1618
|
+
console.log(`Running pipeline: ${file}`);
|
|
1619
|
+
const { parse: parse2 } = await import('yaml');
|
|
1620
|
+
const pipelineContent = await fs3.readFile(pipelinePath, "utf8");
|
|
1621
|
+
const parsedPipeline = parse2(pipelineContent);
|
|
1622
|
+
const hasConfigFile = await fileExists(CONFIG_FILENAME);
|
|
1623
|
+
let parsedConfig = void 0;
|
|
1624
|
+
if (hasConfigFile) {
|
|
1625
|
+
const configContent = await fs3.readFile(CONFIG_FILENAME, "utf8");
|
|
1626
|
+
parsedConfig = parse2(configContent);
|
|
1627
|
+
}
|
|
1628
|
+
const configMissing = parsedConfig ? collectMissingEnvVars(parsedConfig) : [];
|
|
1629
|
+
const pipelineMissing = collectMissingEnvVars(parsedPipeline);
|
|
1630
|
+
const allMissing = [.../* @__PURE__ */ new Set([...configMissing, ...pipelineMissing])];
|
|
1631
|
+
if (allMissing.length > 0) {
|
|
1632
|
+
const projectRoot = await findProjectRoot(pipelinePath);
|
|
1633
|
+
const canContinue = await validateEnvVars(allMissing, projectRoot || process2.cwd());
|
|
1634
|
+
if (!canContinue) {
|
|
1635
|
+
process2.exit(1);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
const pipeline = await loadPipelineDefinition(pipelinePath);
|
|
1639
|
+
hasConfigFile ? await loadIntellitesterConfig(CONFIG_FILENAME) : void 0;
|
|
1640
|
+
const result = await runPipeline(pipeline, pipelinePath, {
|
|
1641
|
+
headed: options.visible,
|
|
1642
|
+
browser: options.browser,
|
|
1643
|
+
interactive: options.interactive,
|
|
1644
|
+
debug: options.debug
|
|
1645
|
+
});
|
|
1646
|
+
console.log(`
|
|
1647
|
+
Pipeline: ${pipeline.name}`);
|
|
1648
|
+
console.log(`Session ID: ${result.sessionId}`);
|
|
1649
|
+
console.log(`Status: ${result.status}
|
|
1650
|
+
`);
|
|
1651
|
+
for (const workflow of result.workflows) {
|
|
1652
|
+
const icon = workflow.status === "passed" ? "\u2713" : workflow.status === "failed" ? "\u2717" : "\u25CB";
|
|
1653
|
+
console.log(` ${icon} ${workflow.file} (${workflow.status})`);
|
|
1654
|
+
if (workflow.error) {
|
|
1655
|
+
console.log(` Error: ${workflow.error}`);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (result.cleanupResult) {
|
|
1659
|
+
console.log(`
|
|
1660
|
+
Cleanup: ${result.cleanupResult.deleted.length} resources deleted`);
|
|
1661
|
+
if (result.cleanupResult.failed.length > 0) {
|
|
1662
|
+
console.log(` Failed to delete: ${result.cleanupResult.failed.join(", ")}`);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
process2.exit(result.status === "passed" ? 0 : 1);
|
|
1666
|
+
};
|
|
1667
|
+
var main = async () => {
|
|
1668
|
+
const program = new Command();
|
|
1669
|
+
program.name("intellitester").description("AI-powered cross-platform test automation").version("1.0.0");
|
|
1670
|
+
program.command("init").description("Initialize IntelliTester in current directory").action(async () => {
|
|
1671
|
+
try {
|
|
1672
|
+
await initCommand();
|
|
1673
|
+
} catch (error) {
|
|
1674
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1675
|
+
logError(message);
|
|
1676
|
+
process2.exitCode = 1;
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
program.command("validate").description("Validate test YAML files").argument("[file]", "Test file or directory to validate", "tests").action(async (file) => {
|
|
1680
|
+
try {
|
|
1681
|
+
await validateCommand(file);
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1684
|
+
logError(message);
|
|
1685
|
+
process2.exitCode = 1;
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
program.command("run").description("Run test file(s), workflow, or auto-discover tests in tests/ directory").argument("[file]", "Test file, workflow, or pipeline to run (auto-discovers if omitted)").option("--visible", "Run browser in visible mode (not headless)").option("--browser <name>", "Browser to use (chrome, safari, firefox)", "chrome").option("--preview", "Build project and run against preview server").option("--no-server", "Skip auto-starting web server").option("-i, --interactive", "Interactive mode - AI suggests fixes on failure").option("--debug", "Debug mode - verbose logging").action(async (file, options) => {
|
|
1689
|
+
let previewCleanup = null;
|
|
1690
|
+
try {
|
|
1691
|
+
const browser = resolveBrowserName(options.browser || "chrome");
|
|
1692
|
+
if (options.preview) {
|
|
1693
|
+
const hasConfigFile = await fileExists(CONFIG_FILENAME);
|
|
1694
|
+
const config = hasConfigFile ? await loadIntellitesterConfig(CONFIG_FILENAME) : void 0;
|
|
1695
|
+
const { cleanup } = await buildAndPreview(config, process2.cwd());
|
|
1696
|
+
previewCleanup = cleanup;
|
|
1697
|
+
}
|
|
1698
|
+
const runOpts = {
|
|
1699
|
+
visible: options.visible,
|
|
1700
|
+
browser,
|
|
1701
|
+
interactive: options.interactive,
|
|
1702
|
+
debug: options.debug
|
|
1703
|
+
};
|
|
1704
|
+
if (!file) {
|
|
1705
|
+
const discovered = await discoverTestFiles("tests");
|
|
1706
|
+
const total = discovered.pipelines.length + discovered.workflows.length + discovered.tests.length;
|
|
1707
|
+
if (total === 0) {
|
|
1708
|
+
logError("No test files found in tests/ directory. Create .pipeline.yaml, .workflow.yaml, or .test.yaml files.");
|
|
1709
|
+
process2.exit(1);
|
|
1710
|
+
}
|
|
1711
|
+
console.log(`Discovered ${total} test file(s):`);
|
|
1712
|
+
if (discovered.pipelines.length > 0) {
|
|
1713
|
+
console.log(` Pipelines: ${discovered.pipelines.length}`);
|
|
1714
|
+
}
|
|
1715
|
+
if (discovered.workflows.length > 0) {
|
|
1716
|
+
console.log(` Workflows: ${discovered.workflows.length}`);
|
|
1717
|
+
}
|
|
1718
|
+
if (discovered.tests.length > 0) {
|
|
1719
|
+
console.log(` Tests: ${discovered.tests.length}`);
|
|
1720
|
+
}
|
|
1721
|
+
console.log("");
|
|
1722
|
+
let failed = false;
|
|
1723
|
+
for (const pipeline of discovered.pipelines) {
|
|
1724
|
+
try {
|
|
1725
|
+
await runPipelineCommand(pipeline, runOpts);
|
|
1726
|
+
} catch {
|
|
1727
|
+
failed = true;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
for (const workflow of discovered.workflows) {
|
|
1731
|
+
try {
|
|
1732
|
+
await runWorkflowCommand(workflow, runOpts);
|
|
1733
|
+
} catch {
|
|
1734
|
+
failed = true;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
for (const test of discovered.tests) {
|
|
1738
|
+
try {
|
|
1739
|
+
await runTestCommand(test, {
|
|
1740
|
+
headed: options.visible,
|
|
1741
|
+
browser,
|
|
1742
|
+
noServer: !options.server,
|
|
1743
|
+
interactive: options.interactive,
|
|
1744
|
+
debug: options.debug
|
|
1745
|
+
});
|
|
1746
|
+
} catch {
|
|
1747
|
+
failed = true;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (failed) {
|
|
1751
|
+
process2.exitCode = 1;
|
|
1752
|
+
}
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (isPipelineFile(file)) {
|
|
1756
|
+
await runPipelineCommand(file, runOpts);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (isWorkflowFile(file)) {
|
|
1760
|
+
await runWorkflowCommand(file, runOpts);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
await runTestCommand(file, {
|
|
1764
|
+
headed: options.visible,
|
|
1765
|
+
browser,
|
|
1766
|
+
noServer: !options.server,
|
|
1767
|
+
interactive: options.interactive,
|
|
1768
|
+
debug: options.debug
|
|
1769
|
+
});
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1772
|
+
logError(message);
|
|
1773
|
+
process2.exitCode = 1;
|
|
1774
|
+
} finally {
|
|
1775
|
+
if (previewCleanup) {
|
|
1776
|
+
previewCleanup();
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
});
|
|
1780
|
+
program.command("generate").description("Generate test from natural language").argument("<description>", "Natural language description of the test").option("--output <file>", "Output file path").option("--platform <platform>", "Target platform", "web").option("--baseUrl <url>", "Base URL for the app").option("--pagesDir <dir>", "Pages directory for source scanning").option("--componentsDir <dir>", "Components directory for source scanning").option("--no-source", "Disable source scanning").action(async (description, options) => {
|
|
1781
|
+
try {
|
|
1782
|
+
await generateCommand(description, {
|
|
1783
|
+
output: options.output,
|
|
1784
|
+
platform: options.platform,
|
|
1785
|
+
baseUrl: options.baseUrl,
|
|
1786
|
+
pagesDir: options.pagesDir,
|
|
1787
|
+
componentsDir: options.componentsDir,
|
|
1788
|
+
noSource: !options.source
|
|
1789
|
+
});
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1792
|
+
logError(message);
|
|
1793
|
+
process2.exitCode = 1;
|
|
1794
|
+
}
|
|
1795
|
+
});
|
|
1796
|
+
program.command("cleanup:list").description("List pending failed cleanup operations from previous test runs").action(async () => {
|
|
1797
|
+
try {
|
|
1798
|
+
const failedCleanups = await loadFailedCleanups(process2.cwd());
|
|
1799
|
+
if (failedCleanups.length === 0) {
|
|
1800
|
+
console.log("No failed cleanups found.");
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
console.log(`
|
|
1804
|
+
Found ${failedCleanups.length} failed cleanup(s):
|
|
1805
|
+
`);
|
|
1806
|
+
for (const failed of failedCleanups) {
|
|
1807
|
+
console.log(`Session: ${failed.sessionId}`);
|
|
1808
|
+
console.log(` Timestamp: ${failed.timestamp}`);
|
|
1809
|
+
console.log(` Provider: ${failed.providerConfig.provider}`);
|
|
1810
|
+
console.log(` Resources: ${failed.resources.length}`);
|
|
1811
|
+
for (const resource of failed.resources) {
|
|
1812
|
+
console.log(` - ${resource.type}:${resource.id}`);
|
|
1813
|
+
}
|
|
1814
|
+
console.log(` Errors: ${failed.errors.length}`);
|
|
1815
|
+
for (const error of failed.errors.slice(0, 3)) {
|
|
1816
|
+
console.log(` - ${error}`);
|
|
1817
|
+
}
|
|
1818
|
+
if (failed.errors.length > 3) {
|
|
1819
|
+
console.log(` ... and ${failed.errors.length - 3} more`);
|
|
1820
|
+
}
|
|
1821
|
+
console.log("");
|
|
1822
|
+
}
|
|
1823
|
+
console.log(`Use 'intellitester cleanup:retry' to retry these cleanups.
|
|
1824
|
+
`);
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
1827
|
+
process2.exit(1);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
program.command("cleanup:retry").description("Retry failed cleanup operations from previous test runs").action(async () => {
|
|
1831
|
+
try {
|
|
1832
|
+
const hasConfigFile = await fileExists(CONFIG_FILENAME);
|
|
1833
|
+
if (!hasConfigFile) {
|
|
1834
|
+
throw new Error(`No ${CONFIG_FILENAME} found. Cannot retry cleanup without provider configuration.`);
|
|
1835
|
+
}
|
|
1836
|
+
const config = await loadIntellitesterConfig(CONFIG_FILENAME);
|
|
1837
|
+
const failedCleanups = await loadFailedCleanups(process2.cwd());
|
|
1838
|
+
if (failedCleanups.length === 0) {
|
|
1839
|
+
console.log("No failed cleanups to retry.");
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
console.log(`Found ${failedCleanups.length} failed cleanup(s) to retry.`);
|
|
1843
|
+
for (const failed of failedCleanups) {
|
|
1844
|
+
console.log(`
|
|
1845
|
+
Retrying cleanup for session ${failed.sessionId}...`);
|
|
1846
|
+
console.log(` Provider: ${failed.providerConfig.provider}`);
|
|
1847
|
+
console.log(` Resources: ${failed.resources.length}`);
|
|
1848
|
+
const provider = failed.providerConfig.provider;
|
|
1849
|
+
const cleanupConfig = {
|
|
1850
|
+
provider,
|
|
1851
|
+
parallel: false,
|
|
1852
|
+
retries: 3
|
|
1853
|
+
};
|
|
1854
|
+
const configAny = config;
|
|
1855
|
+
if (provider === "appwrite") {
|
|
1856
|
+
if (!config.appwrite?.apiKey) {
|
|
1857
|
+
console.log(` \u2717 Skipping: Appwrite API key not configured in ${CONFIG_FILENAME}`);
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
cleanupConfig.appwrite = {
|
|
1861
|
+
endpoint: failed.providerConfig.endpoint,
|
|
1862
|
+
projectId: failed.providerConfig.projectId,
|
|
1863
|
+
apiKey: config.appwrite.apiKey
|
|
1864
|
+
};
|
|
1865
|
+
} else if (provider === "postgres") {
|
|
1866
|
+
const pgConfig = configAny.postgres;
|
|
1867
|
+
if (!pgConfig?.connectionString && !pgConfig?.password) {
|
|
1868
|
+
console.log(` \u2717 Skipping: Postgres credentials not configured in ${CONFIG_FILENAME}`);
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
if (pgConfig.connectionString) {
|
|
1872
|
+
cleanupConfig.postgres = {
|
|
1873
|
+
connectionString: pgConfig.connectionString
|
|
1874
|
+
};
|
|
1875
|
+
} else {
|
|
1876
|
+
const host = failed.providerConfig.host;
|
|
1877
|
+
const port = failed.providerConfig.port;
|
|
1878
|
+
const database = failed.providerConfig.database;
|
|
1879
|
+
const user = failed.providerConfig.user;
|
|
1880
|
+
const password = pgConfig.password;
|
|
1881
|
+
cleanupConfig.postgres = {
|
|
1882
|
+
connectionString: `postgresql://${user}:${password}@${host}:${port}/${database}`
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
} else if (provider === "mysql") {
|
|
1886
|
+
const mysqlConfig = configAny.mysql;
|
|
1887
|
+
if (!mysqlConfig?.password) {
|
|
1888
|
+
console.log(` \u2717 Skipping: MySQL password not configured in ${CONFIG_FILENAME}`);
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
cleanupConfig.mysql = {
|
|
1892
|
+
host: failed.providerConfig.host,
|
|
1893
|
+
port: failed.providerConfig.port,
|
|
1894
|
+
user: failed.providerConfig.user,
|
|
1895
|
+
password: mysqlConfig.password,
|
|
1896
|
+
database: failed.providerConfig.database
|
|
1897
|
+
};
|
|
1898
|
+
} else if (provider === "sqlite") {
|
|
1899
|
+
const sqliteConfig = configAny.sqlite;
|
|
1900
|
+
if (!sqliteConfig?.database && !failed.providerConfig.database) {
|
|
1901
|
+
console.log(` \u2717 Skipping: SQLite database path not configured`);
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
cleanupConfig.sqlite = {
|
|
1905
|
+
database: failed.providerConfig.database || sqliteConfig?.database
|
|
1906
|
+
};
|
|
1907
|
+
} else {
|
|
1908
|
+
console.log(` \u2717 Skipping: Unknown provider "${provider}"`);
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
try {
|
|
1912
|
+
const { handlers, typeMappings } = await loadCleanupHandlers(
|
|
1913
|
+
cleanupConfig,
|
|
1914
|
+
process2.cwd()
|
|
1915
|
+
);
|
|
1916
|
+
const result = await executeCleanup(
|
|
1917
|
+
failed.resources,
|
|
1918
|
+
handlers,
|
|
1919
|
+
typeMappings,
|
|
1920
|
+
{
|
|
1921
|
+
parallel: false,
|
|
1922
|
+
retries: 3,
|
|
1923
|
+
cwd: process2.cwd()
|
|
1924
|
+
// Don't save failed cleanups again during retry
|
|
1925
|
+
}
|
|
1926
|
+
);
|
|
1927
|
+
if (result.success) {
|
|
1928
|
+
console.log(` \u2713 Successfully cleaned up ${result.deleted.length} resources`);
|
|
1929
|
+
await removeFailedCleanup(failed.sessionId, process2.cwd());
|
|
1930
|
+
} else {
|
|
1931
|
+
console.log(` \u26A0 Partial cleanup: ${result.deleted.length} deleted, ${result.failed.length} failed`);
|
|
1932
|
+
for (const failedResource of result.failed) {
|
|
1933
|
+
console.log(` \u2717 ${failedResource}`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
} catch (error) {
|
|
1937
|
+
console.log(` \u2717 Error during cleanup: ${error instanceof Error ? error.message : String(error)}`);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
const remaining = await loadFailedCleanups(process2.cwd());
|
|
1941
|
+
if (remaining.length === 0) {
|
|
1942
|
+
console.log("\n\u2713 All failed cleanups have been resolved.");
|
|
1943
|
+
} else {
|
|
1944
|
+
console.log(`
|
|
1945
|
+
\u26A0 ${remaining.length} failed cleanup(s) still remaining.`);
|
|
1946
|
+
console.log(` Use 'intellitester cleanup:list' to see details.`);
|
|
1947
|
+
}
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
1950
|
+
process2.exit(1);
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
await program.parseAsync(process2.argv);
|
|
1954
|
+
};
|
|
1955
|
+
main();
|
|
1956
|
+
//# sourceMappingURL=index.js.map
|
|
1957
|
+
//# sourceMappingURL=index.js.map
|