slapify 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +452 -0
- package/bin/slapify.js +2 -0
- package/dist/ai/interpreter.d.ts +54 -0
- package/dist/ai/interpreter.d.ts.map +1 -0
- package/dist/ai/interpreter.js +293 -0
- package/dist/ai/interpreter.js.map +1 -0
- package/dist/browser/agent.d.ts +137 -0
- package/dist/browser/agent.d.ts.map +1 -0
- package/dist/browser/agent.js +362 -0
- package/dist/browser/agent.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1181 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/loader.d.ts +35 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +278 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +260 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/flow.d.ts +27 -0
- package/dist/parser/flow.d.ts.map +1 -0
- package/dist/parser/flow.js +117 -0
- package/dist/parser/flow.js.map +1 -0
- package/dist/report/generator.d.ts +61 -0
- package/dist/report/generator.d.ts.map +1 -0
- package/dist/report/generator.js +549 -0
- package/dist/report/generator.js.map +1 -0
- package/dist/runner/index.d.ts +44 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/index.js +403 -0
- package/dist/runner/index.js.map +1 -0
- package/dist/types.d.ts +105 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import dotenv from "dotenv";
|
|
8
|
+
import { loadConfig, loadCredentials, getConfigDir, } from "./config/loader.js";
|
|
9
|
+
import { parseFlowFile, findFlowFiles, validateFlowFile, getFlowSummary, } from "./parser/flow.js";
|
|
10
|
+
import { TestRunner } from "./runner/index.js";
|
|
11
|
+
import { ReportGenerator } from "./report/generator.js";
|
|
12
|
+
import { BrowserAgent } from "./browser/agent.js";
|
|
13
|
+
import { generateText } from "ai";
|
|
14
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
15
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
16
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
17
|
+
import { createMistral } from "@ai-sdk/mistral";
|
|
18
|
+
import { createGroq } from "@ai-sdk/groq";
|
|
19
|
+
// Load environment variables
|
|
20
|
+
dotenv.config();
|
|
21
|
+
/**
|
|
22
|
+
* Get AI model based on config
|
|
23
|
+
*/
|
|
24
|
+
function getModelFromConfig(llmConfig) {
|
|
25
|
+
switch (llmConfig.provider) {
|
|
26
|
+
case "anthropic": {
|
|
27
|
+
const anthropic = createAnthropic({ apiKey: llmConfig.api_key });
|
|
28
|
+
return anthropic(llmConfig.model);
|
|
29
|
+
}
|
|
30
|
+
case "openai": {
|
|
31
|
+
const openai = createOpenAI({ apiKey: llmConfig.api_key });
|
|
32
|
+
return openai(llmConfig.model);
|
|
33
|
+
}
|
|
34
|
+
case "google": {
|
|
35
|
+
const google = createGoogleGenerativeAI({ apiKey: llmConfig.api_key });
|
|
36
|
+
return google(llmConfig.model);
|
|
37
|
+
}
|
|
38
|
+
case "mistral": {
|
|
39
|
+
const mistral = createMistral({ apiKey: llmConfig.api_key });
|
|
40
|
+
return mistral(llmConfig.model);
|
|
41
|
+
}
|
|
42
|
+
case "groq": {
|
|
43
|
+
const groq = createGroq({ apiKey: llmConfig.api_key });
|
|
44
|
+
return groq(llmConfig.model);
|
|
45
|
+
}
|
|
46
|
+
case "ollama": {
|
|
47
|
+
const ollama = createOpenAI({
|
|
48
|
+
apiKey: "ollama",
|
|
49
|
+
baseURL: llmConfig.base_url || "http://localhost:11434/v1",
|
|
50
|
+
});
|
|
51
|
+
return ollama(llmConfig.model);
|
|
52
|
+
}
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`Unsupported provider: ${llmConfig.provider}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const program = new Command();
|
|
58
|
+
program
|
|
59
|
+
.name("slapify")
|
|
60
|
+
.description("AI-powered test automation using natural language flow files")
|
|
61
|
+
.version("0.1.0");
|
|
62
|
+
// Init command
|
|
63
|
+
program
|
|
64
|
+
.command("init")
|
|
65
|
+
.description("Initialize Slapify in the current directory")
|
|
66
|
+
.option("-y, --yes", "Skip prompts and use defaults")
|
|
67
|
+
.action(async (options) => {
|
|
68
|
+
const readline = await import("readline");
|
|
69
|
+
// Check if already initialized
|
|
70
|
+
if (fs.existsSync(".slapify")) {
|
|
71
|
+
console.log(chalk.yellow("Slapify is already initialized in this directory."));
|
|
72
|
+
console.log(chalk.gray("Delete .slapify folder to reinitialize."));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
console.log(chalk.blue.bold("\n🖐️ Welcome to Slapify!\n"));
|
|
76
|
+
console.log(chalk.gray("AI-powered E2E testing that slaps - by slaps.dev\n"));
|
|
77
|
+
// Import the new functions
|
|
78
|
+
const { findSystemBrowsers, initConfig: doInit } = await import("./config/loader.js");
|
|
79
|
+
let provider = "anthropic";
|
|
80
|
+
let model;
|
|
81
|
+
let browserPath;
|
|
82
|
+
let useSystemBrowser;
|
|
83
|
+
const providerInfo = {
|
|
84
|
+
anthropic: {
|
|
85
|
+
name: "Anthropic (Claude)",
|
|
86
|
+
envVar: "ANTHROPIC_API_KEY",
|
|
87
|
+
defaultModel: "claude-haiku-4-5-20251001",
|
|
88
|
+
models: [
|
|
89
|
+
{
|
|
90
|
+
id: "claude-haiku-4-5-20251001",
|
|
91
|
+
name: "Haiku 4.5 - fast & cheap ($1/5M tokens)",
|
|
92
|
+
recommended: true,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "claude-sonnet-4-20250514",
|
|
96
|
+
name: "Sonnet 4 - more capable ($3/15M tokens)",
|
|
97
|
+
},
|
|
98
|
+
{ id: "custom", name: "Enter custom model ID" },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
openai: {
|
|
102
|
+
name: "OpenAI",
|
|
103
|
+
envVar: "OPENAI_API_KEY",
|
|
104
|
+
defaultModel: "gpt-4o-mini",
|
|
105
|
+
models: [
|
|
106
|
+
{
|
|
107
|
+
id: "gpt-4o-mini",
|
|
108
|
+
name: "GPT-4o Mini - fast & cheap ($0.15/0.6M tokens)",
|
|
109
|
+
recommended: true,
|
|
110
|
+
},
|
|
111
|
+
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini - newer" },
|
|
112
|
+
{ id: "gpt-4o", name: "GPT-4o - more capable ($2.5/10M tokens)" },
|
|
113
|
+
{ id: "custom", name: "Enter custom model ID" },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
google: {
|
|
117
|
+
name: "Google (Gemini)",
|
|
118
|
+
envVar: "GOOGLE_API_KEY",
|
|
119
|
+
defaultModel: "gemini-2.0-flash",
|
|
120
|
+
models: [
|
|
121
|
+
{
|
|
122
|
+
id: "gemini-2.0-flash",
|
|
123
|
+
name: "Gemini 2.0 Flash - fastest & cheapest",
|
|
124
|
+
recommended: true,
|
|
125
|
+
},
|
|
126
|
+
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash - stable" },
|
|
127
|
+
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro - more capable" },
|
|
128
|
+
{ id: "custom", name: "Enter custom model ID" },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
mistral: {
|
|
132
|
+
name: "Mistral",
|
|
133
|
+
envVar: "MISTRAL_API_KEY",
|
|
134
|
+
askModel: true,
|
|
135
|
+
defaultModel: "mistral-small-latest",
|
|
136
|
+
},
|
|
137
|
+
groq: {
|
|
138
|
+
name: "Groq (Fast inference)",
|
|
139
|
+
envVar: "GROQ_API_KEY",
|
|
140
|
+
askModel: true,
|
|
141
|
+
defaultModel: "llama-3.3-70b-versatile",
|
|
142
|
+
},
|
|
143
|
+
ollama: {
|
|
144
|
+
name: "Ollama (Local)",
|
|
145
|
+
envVar: "",
|
|
146
|
+
askModel: true,
|
|
147
|
+
defaultModel: "llama3",
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
if (options.yes) {
|
|
151
|
+
// Use defaults
|
|
152
|
+
console.log(chalk.gray("Using default settings...\n"));
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const rl = readline.createInterface({
|
|
156
|
+
input: process.stdin,
|
|
157
|
+
output: process.stdout,
|
|
158
|
+
});
|
|
159
|
+
const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
|
|
160
|
+
// Step 1: LLM Provider
|
|
161
|
+
console.log(chalk.cyan("1. Choose your LLM provider:\n"));
|
|
162
|
+
console.log(" 1) Anthropic (Claude) " + chalk.green("- recommended"));
|
|
163
|
+
console.log(" 2) OpenAI (GPT-4)");
|
|
164
|
+
console.log(" 3) Google (Gemini)");
|
|
165
|
+
console.log(" 4) Mistral");
|
|
166
|
+
console.log(" 5) Groq " + chalk.gray("- fast & free tier"));
|
|
167
|
+
console.log(" 6) Ollama " + chalk.gray("- local, no API key"));
|
|
168
|
+
console.log("");
|
|
169
|
+
const providerChoice = await question(chalk.white(" Select [1]: "));
|
|
170
|
+
const providerMap = {
|
|
171
|
+
"1": "anthropic",
|
|
172
|
+
"2": "openai",
|
|
173
|
+
"3": "google",
|
|
174
|
+
"4": "mistral",
|
|
175
|
+
"5": "groq",
|
|
176
|
+
"6": "ollama",
|
|
177
|
+
};
|
|
178
|
+
provider = providerMap[providerChoice] || "anthropic";
|
|
179
|
+
const info = providerInfo[provider];
|
|
180
|
+
console.log(chalk.green(` ✓ Using ${info.name}\n`));
|
|
181
|
+
// Step 1b: Model Selection
|
|
182
|
+
if (info.models && info.models.length > 0) {
|
|
183
|
+
console.log(chalk.cyan(" Choose model:\n"));
|
|
184
|
+
info.models.forEach((m, i) => {
|
|
185
|
+
const rec = m.recommended ? chalk.green(" ← recommended") : "";
|
|
186
|
+
console.log(` ${i + 1}) ${m.name}${rec}`);
|
|
187
|
+
});
|
|
188
|
+
console.log("");
|
|
189
|
+
const modelChoice = await question(chalk.white(" Select [1]: "));
|
|
190
|
+
const modelIdx = parseInt(modelChoice) - 1 || 0;
|
|
191
|
+
const selectedModel = info.models[modelIdx];
|
|
192
|
+
if (selectedModel?.id === "custom") {
|
|
193
|
+
const customModel = await question(chalk.white(" Enter model ID: "));
|
|
194
|
+
model = customModel.trim() || info.defaultModel;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
model = selectedModel?.id || info.defaultModel;
|
|
198
|
+
}
|
|
199
|
+
console.log(chalk.green(` ✓ Using model: ${model}\n`));
|
|
200
|
+
}
|
|
201
|
+
else if (info.askModel) {
|
|
202
|
+
// For Mistral, Groq, Ollama - ask for model ID directly
|
|
203
|
+
console.log(chalk.gray(` Enter model ID (default: ${info.defaultModel})`));
|
|
204
|
+
if (provider === "ollama") {
|
|
205
|
+
console.log(chalk.gray(" Common models: llama3, mistral, codellama, phi3"));
|
|
206
|
+
}
|
|
207
|
+
else if (provider === "groq") {
|
|
208
|
+
console.log(chalk.gray(" Common models: llama-3.3-70b-versatile, mixtral-8x7b-32768"));
|
|
209
|
+
}
|
|
210
|
+
else if (provider === "mistral") {
|
|
211
|
+
console.log(chalk.gray(" Common models: mistral-small-latest, mistral-large-latest"));
|
|
212
|
+
}
|
|
213
|
+
console.log("");
|
|
214
|
+
const modelInput = await question(chalk.white(` Model [${info.defaultModel}]: `));
|
|
215
|
+
model = modelInput.trim() || info.defaultModel;
|
|
216
|
+
console.log(chalk.green(` ✓ Using model: ${model}\n`));
|
|
217
|
+
if (provider === "ollama") {
|
|
218
|
+
console.log(chalk.gray(" Make sure Ollama is running: ollama serve"));
|
|
219
|
+
console.log("");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Step 2: API Key Verification (skip for Ollama)
|
|
223
|
+
if (provider !== "ollama") {
|
|
224
|
+
console.log(chalk.cyan("2. API Key verification:\n"));
|
|
225
|
+
const envVar = info.envVar;
|
|
226
|
+
let apiKey = process.env[envVar];
|
|
227
|
+
if (apiKey) {
|
|
228
|
+
console.log(chalk.gray(` Found ${envVar} in environment`));
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
console.log(chalk.yellow(` ${envVar} not found in environment`));
|
|
232
|
+
console.log(chalk.gray(" You can set it now or add it to your shell config later.\n"));
|
|
233
|
+
const keyInput = await question(chalk.white(` Enter API key (or press Enter to skip): `));
|
|
234
|
+
if (keyInput.trim()) {
|
|
235
|
+
apiKey = keyInput.trim();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (apiKey) {
|
|
239
|
+
const verifyChoice = await question(chalk.white(" Verify API key with test call? (Y/n): "));
|
|
240
|
+
if (verifyChoice.toLowerCase() !== "n") {
|
|
241
|
+
const verifySpinner = ora(" Verifying API key...").start();
|
|
242
|
+
try {
|
|
243
|
+
// Make a minimal test call
|
|
244
|
+
const testConfig = {
|
|
245
|
+
provider,
|
|
246
|
+
model: model || info.defaultModel || "test",
|
|
247
|
+
api_key: apiKey,
|
|
248
|
+
};
|
|
249
|
+
const testModel = getModelFromConfig(testConfig);
|
|
250
|
+
const response = await generateText({
|
|
251
|
+
model: testModel,
|
|
252
|
+
prompt: "Reply with only the word 'pong'",
|
|
253
|
+
maxTokens: 10,
|
|
254
|
+
});
|
|
255
|
+
if (response.text.toLowerCase().includes("pong")) {
|
|
256
|
+
verifySpinner.succeed(chalk.green("API key verified! ✓"));
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
verifySpinner.succeed(chalk.green("API key works! (got response)"));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
verifySpinner.fail(chalk.red("API key verification failed"));
|
|
264
|
+
console.log(chalk.red(` Error: ${error.message}`));
|
|
265
|
+
console.log(chalk.yellow("\n You can still continue - check your API key later."));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
console.log(chalk.gray(" Skipping verification\n"));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
console.log(chalk.yellow(`\n Remember to set ${envVar} before running tests.`));
|
|
274
|
+
}
|
|
275
|
+
console.log("");
|
|
276
|
+
}
|
|
277
|
+
// Step 3: Browser Setup
|
|
278
|
+
console.log(chalk.cyan(`${provider === "ollama" ? "2" : "3"}. Browser setup:\n`));
|
|
279
|
+
const systemBrowsers = findSystemBrowsers();
|
|
280
|
+
if (systemBrowsers.length > 0) {
|
|
281
|
+
console.log(" Found browsers on your system:");
|
|
282
|
+
systemBrowsers.forEach((b, i) => {
|
|
283
|
+
console.log(chalk.gray(` ${i + 1}) ${b.name}`));
|
|
284
|
+
});
|
|
285
|
+
console.log(chalk.gray(` ${systemBrowsers.length + 1}) Download Chromium (~170MB)`));
|
|
286
|
+
console.log(chalk.gray(` ${systemBrowsers.length + 2}) Enter custom path`));
|
|
287
|
+
console.log("");
|
|
288
|
+
const browserChoice = await question(chalk.white(` Select [1]: `));
|
|
289
|
+
const choiceNum = parseInt(browserChoice) || 1;
|
|
290
|
+
if (choiceNum <= systemBrowsers.length) {
|
|
291
|
+
browserPath = systemBrowsers[choiceNum - 1].path;
|
|
292
|
+
useSystemBrowser = true;
|
|
293
|
+
console.log(chalk.green(` ✓ Using ${systemBrowsers[choiceNum - 1].name}\n`));
|
|
294
|
+
}
|
|
295
|
+
else if (choiceNum === systemBrowsers.length + 2) {
|
|
296
|
+
const customPath = await question(chalk.white(" Enter browser path: "));
|
|
297
|
+
if (customPath.trim()) {
|
|
298
|
+
browserPath = customPath.trim();
|
|
299
|
+
useSystemBrowser = true;
|
|
300
|
+
console.log(chalk.green(` ✓ Using custom browser\n`));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
useSystemBrowser = false;
|
|
305
|
+
console.log(chalk.green(" ✓ Will download Chromium on first run\n"));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
console.log(" No browsers found. Options:");
|
|
310
|
+
console.log(chalk.gray(" 1) Download Chromium automatically (~170MB)"));
|
|
311
|
+
console.log(chalk.gray(" 2) Enter custom browser path"));
|
|
312
|
+
console.log("");
|
|
313
|
+
const browserChoice = await question(chalk.white(" Select [1]: "));
|
|
314
|
+
if (browserChoice === "2") {
|
|
315
|
+
const customPath = await question(chalk.white(" Enter browser path: "));
|
|
316
|
+
if (customPath.trim()) {
|
|
317
|
+
browserPath = customPath.trim();
|
|
318
|
+
useSystemBrowser = true;
|
|
319
|
+
console.log(chalk.green(" ✓ Using custom browser\n"));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
useSystemBrowser = false;
|
|
324
|
+
console.log(chalk.green(" ✓ Will download Chromium on first run\n"));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
rl.close();
|
|
328
|
+
}
|
|
329
|
+
// Initialize
|
|
330
|
+
const spinner = ora("Creating configuration...").start();
|
|
331
|
+
try {
|
|
332
|
+
doInit(process.cwd(), {
|
|
333
|
+
provider,
|
|
334
|
+
model,
|
|
335
|
+
browserPath,
|
|
336
|
+
useSystemBrowser,
|
|
337
|
+
});
|
|
338
|
+
spinner.succeed("Slapify initialized!");
|
|
339
|
+
console.log("");
|
|
340
|
+
console.log(chalk.green("Created:"));
|
|
341
|
+
console.log(" 📁 .slapify/config.yaml - Configuration");
|
|
342
|
+
console.log(" 🔐 .slapify/credentials.yaml - Credentials (gitignored)");
|
|
343
|
+
console.log(" 📝 tests/example.flow - Sample test");
|
|
344
|
+
console.log("");
|
|
345
|
+
const info = providerInfo[provider];
|
|
346
|
+
console.log(chalk.yellow("Next steps:"));
|
|
347
|
+
console.log("");
|
|
348
|
+
if (provider === "ollama") {
|
|
349
|
+
console.log(chalk.white(` 1. Make sure Ollama is running:`));
|
|
350
|
+
console.log(chalk.cyan(` ollama serve`));
|
|
351
|
+
console.log(chalk.cyan(` ollama pull llama3`));
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
console.log(chalk.white(` 1. Set your API key:`));
|
|
355
|
+
console.log(chalk.cyan(` export ${info.envVar}=your-key-here`));
|
|
356
|
+
}
|
|
357
|
+
console.log("");
|
|
358
|
+
console.log(chalk.white(` 2. Run the example test:`));
|
|
359
|
+
console.log(chalk.cyan(` slapify run tests/example.flow`));
|
|
360
|
+
console.log("");
|
|
361
|
+
console.log(chalk.white(` 3. Create your own tests:`));
|
|
362
|
+
console.log(chalk.cyan(` slapify create my-first-test`));
|
|
363
|
+
console.log(chalk.cyan(` slapify generate "test login for myapp.com"`));
|
|
364
|
+
console.log("");
|
|
365
|
+
console.log(chalk.gray(" Config can be modified anytime in .slapify/config.yaml"));
|
|
366
|
+
console.log("");
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
spinner.fail(error.message);
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
// Install command (for agent-browser)
|
|
374
|
+
program
|
|
375
|
+
.command("install")
|
|
376
|
+
.description("Install browser dependencies")
|
|
377
|
+
.action(() => {
|
|
378
|
+
const spinner = ora("Checking agent-browser...").start();
|
|
379
|
+
if (BrowserAgent.isInstalled()) {
|
|
380
|
+
spinner.succeed("agent-browser is already installed");
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
spinner.text = "Installing agent-browser...";
|
|
384
|
+
try {
|
|
385
|
+
BrowserAgent.install();
|
|
386
|
+
spinner.succeed("Browser dependencies installed!");
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
spinner.fail(`Installation failed: ${error.message}`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
// Run command
|
|
395
|
+
program
|
|
396
|
+
.command("run [files...]")
|
|
397
|
+
.description("Run flow test files")
|
|
398
|
+
.option("--headed", "Run browser in headed mode (visible)")
|
|
399
|
+
.option("--report [format]", "Generate report folder (html, markdown, json)")
|
|
400
|
+
.option("--output <dir>", "Output directory for reports", "./test-reports")
|
|
401
|
+
.option("--credentials <profile>", "Default credentials profile to use")
|
|
402
|
+
.option("-p, --parallel", "Run tests in parallel")
|
|
403
|
+
.option("-w, --workers <n>", "Number of parallel workers (default: 4)", "4")
|
|
404
|
+
.action(async (files, options) => {
|
|
405
|
+
try {
|
|
406
|
+
// Load configuration
|
|
407
|
+
const configDir = getConfigDir();
|
|
408
|
+
if (!configDir) {
|
|
409
|
+
console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
const config = loadConfig(configDir);
|
|
413
|
+
const credentials = loadCredentials(configDir);
|
|
414
|
+
// Apply CLI options
|
|
415
|
+
if (options.headed) {
|
|
416
|
+
config.browser = { ...config.browser, headless: false };
|
|
417
|
+
}
|
|
418
|
+
// Only enable screenshots if report is requested
|
|
419
|
+
const generateReport = options.report !== undefined;
|
|
420
|
+
config.report = {
|
|
421
|
+
...config.report,
|
|
422
|
+
format: typeof options.report === "string" ? options.report : "html",
|
|
423
|
+
output_dir: options.output,
|
|
424
|
+
screenshots: generateReport, // Only take screenshots for reports
|
|
425
|
+
};
|
|
426
|
+
// Find flow files
|
|
427
|
+
let flowFiles = [];
|
|
428
|
+
if (files.length === 0) {
|
|
429
|
+
// Run all flow files in tests directory
|
|
430
|
+
const testsDir = path.join(process.cwd(), "tests");
|
|
431
|
+
if (fs.existsSync(testsDir)) {
|
|
432
|
+
flowFiles = await findFlowFiles(testsDir);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
for (const file of files) {
|
|
437
|
+
if (fs.statSync(file).isDirectory()) {
|
|
438
|
+
const found = await findFlowFiles(file);
|
|
439
|
+
flowFiles.push(...found);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
flowFiles.push(file);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (flowFiles.length === 0) {
|
|
447
|
+
console.log(chalk.yellow("No .flow files found to run."));
|
|
448
|
+
process.exit(0);
|
|
449
|
+
}
|
|
450
|
+
const reporter = new ReportGenerator(config.report);
|
|
451
|
+
const results = [];
|
|
452
|
+
const isParallel = options.parallel && flowFiles.length > 1;
|
|
453
|
+
const workers = parseInt(options.workers) || 4;
|
|
454
|
+
if (isParallel) {
|
|
455
|
+
// Parallel execution
|
|
456
|
+
console.log(chalk.blue.bold(`\n━━━ Running ${flowFiles.length} tests in parallel (${workers} workers) ━━━\n`));
|
|
457
|
+
const pending = [...flowFiles];
|
|
458
|
+
const running = new Map();
|
|
459
|
+
const testStatus = new Map();
|
|
460
|
+
// Initialize status
|
|
461
|
+
for (const file of flowFiles) {
|
|
462
|
+
const name = path.basename(file, ".flow");
|
|
463
|
+
testStatus.set(name, chalk.gray("⏳ pending"));
|
|
464
|
+
}
|
|
465
|
+
const printStatus = () => {
|
|
466
|
+
// Clear and reprint status
|
|
467
|
+
process.stdout.write("\x1B[" + flowFiles.length + "A"); // Move cursor up
|
|
468
|
+
for (const file of flowFiles) {
|
|
469
|
+
const name = path.basename(file, ".flow");
|
|
470
|
+
const status = testStatus.get(name) || "";
|
|
471
|
+
process.stdout.write("\x1B[2K"); // Clear line
|
|
472
|
+
console.log(` ${name}: ${status}`);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
// Print initial status
|
|
476
|
+
for (const file of flowFiles) {
|
|
477
|
+
const name = path.basename(file, ".flow");
|
|
478
|
+
console.log(` ${name}: ${testStatus.get(name)}`);
|
|
479
|
+
}
|
|
480
|
+
const runTest = async (file) => {
|
|
481
|
+
const flow = parseFlowFile(file);
|
|
482
|
+
const name = flow.name;
|
|
483
|
+
testStatus.set(name, chalk.cyan("▶ running..."));
|
|
484
|
+
printStatus();
|
|
485
|
+
try {
|
|
486
|
+
const runner = new TestRunner(config, credentials);
|
|
487
|
+
const result = await runner.runFlow(flow);
|
|
488
|
+
results.push(result);
|
|
489
|
+
if (result.status === "passed") {
|
|
490
|
+
testStatus.set(name, chalk.green(`✓ passed (${result.passedSteps}/${result.totalSteps} steps, ${(result.duration / 1000).toFixed(1)}s)`));
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
testStatus.set(name, chalk.red(`✗ failed (${result.failedSteps} failed, ${result.passedSteps} passed)`));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
testStatus.set(name, chalk.red(`✗ error: ${error.message}`));
|
|
498
|
+
}
|
|
499
|
+
printStatus();
|
|
500
|
+
};
|
|
501
|
+
// Process with worker limit
|
|
502
|
+
while (pending.length > 0 || running.size > 0) {
|
|
503
|
+
// Start new tasks up to worker limit
|
|
504
|
+
while (pending.length > 0 && running.size < workers) {
|
|
505
|
+
const file = pending.shift();
|
|
506
|
+
const promise = runTest(file).then(() => {
|
|
507
|
+
running.delete(file);
|
|
508
|
+
});
|
|
509
|
+
running.set(file, promise);
|
|
510
|
+
}
|
|
511
|
+
// Wait for at least one to complete
|
|
512
|
+
if (running.size > 0) {
|
|
513
|
+
await Promise.race(running.values());
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
console.log(""); // Extra newline after parallel run
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
// Sequential execution
|
|
520
|
+
for (const file of flowFiles) {
|
|
521
|
+
const flow = parseFlowFile(file);
|
|
522
|
+
const summary = getFlowSummary(flow);
|
|
523
|
+
// Print test header
|
|
524
|
+
console.log("");
|
|
525
|
+
console.log(chalk.blue.bold(`━━━ ${flow.name} ━━━`));
|
|
526
|
+
console.log(chalk.gray(` ${summary.totalSteps} steps (${summary.requiredSteps} required, ${summary.optionalSteps} optional)`));
|
|
527
|
+
console.log("");
|
|
528
|
+
try {
|
|
529
|
+
const runner = new TestRunner(config, credentials);
|
|
530
|
+
const result = await runner.runFlow(flow, (stepResult) => {
|
|
531
|
+
// Real-time step output
|
|
532
|
+
const step = stepResult.step;
|
|
533
|
+
const statusIcon = stepResult.status === "passed"
|
|
534
|
+
? chalk.green("✓")
|
|
535
|
+
: stepResult.status === "failed"
|
|
536
|
+
? chalk.red("✗")
|
|
537
|
+
: chalk.yellow("⊘");
|
|
538
|
+
const optionalTag = step.optional
|
|
539
|
+
? chalk.gray(" [optional]")
|
|
540
|
+
: "";
|
|
541
|
+
const retriedTag = stepResult.retried
|
|
542
|
+
? chalk.yellow(" [retried]")
|
|
543
|
+
: "";
|
|
544
|
+
const duration = chalk.gray(`(${(stepResult.duration / 1000).toFixed(1)}s)`);
|
|
545
|
+
console.log(` ${statusIcon} ${step.text}${optionalTag}${retriedTag} ${duration}`);
|
|
546
|
+
// Show error inline if failed
|
|
547
|
+
if (stepResult.status === "failed" && stepResult.error) {
|
|
548
|
+
console.log(chalk.red(` └─ ${stepResult.error}`));
|
|
549
|
+
}
|
|
550
|
+
// Show assumptions if any
|
|
551
|
+
if (stepResult.assumptions && stepResult.assumptions.length > 0) {
|
|
552
|
+
for (const assumption of stepResult.assumptions) {
|
|
553
|
+
console.log(chalk.gray(` └─ 💡 ${assumption}`));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
results.push(result);
|
|
558
|
+
// Print test summary
|
|
559
|
+
console.log("");
|
|
560
|
+
if (result.status === "passed") {
|
|
561
|
+
console.log(chalk.green.bold(` ✓ PASSED`) +
|
|
562
|
+
chalk.gray(` (${result.passedSteps}/${result.totalSteps} steps in ${(result.duration / 1000).toFixed(1)}s)`));
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
console.log(chalk.red.bold(` ✗ FAILED`) +
|
|
566
|
+
chalk.gray(` (${result.failedSteps} failed, ${result.passedSteps} passed)`));
|
|
567
|
+
}
|
|
568
|
+
// Auto-handled info
|
|
569
|
+
if (result.autoHandled.length > 0) {
|
|
570
|
+
console.log(chalk.gray(` ℹ Auto-handled: ${result.autoHandled.join(", ")}`));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
console.log(chalk.red(` ✗ ERROR: ${error.message}`));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// Final summary
|
|
579
|
+
console.log("");
|
|
580
|
+
console.log(chalk.blue.bold("━━━ Summary ━━━"));
|
|
581
|
+
const passed = results.filter((r) => r.status === "passed").length;
|
|
582
|
+
const failed = results.filter((r) => r.status === "failed").length;
|
|
583
|
+
const totalSteps = results.reduce((sum, r) => sum + r.totalSteps, 0);
|
|
584
|
+
const passedSteps = results.reduce((sum, r) => sum + r.passedSteps, 0);
|
|
585
|
+
console.log(chalk.gray(` ${results.length} test file(s), ${totalSteps} total steps`));
|
|
586
|
+
if (failed === 0) {
|
|
587
|
+
console.log(chalk.green.bold(` ✓ All ${passed} test(s) passed! (${passedSteps}/${totalSteps} steps)`));
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
console.log(chalk.red.bold(` ✗ ${failed}/${results.length} test(s) failed`));
|
|
591
|
+
}
|
|
592
|
+
// Generate suite report if requested and multiple files
|
|
593
|
+
if (generateReport && results.length > 0) {
|
|
594
|
+
let reportPath;
|
|
595
|
+
if (results.length === 1) {
|
|
596
|
+
reportPath = reporter.saveAsFolder(results[0]);
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
reportPath = reporter.saveSuiteAsFolder(results);
|
|
600
|
+
}
|
|
601
|
+
console.log(chalk.cyan(`\n 📄 Report: ${reportPath}`));
|
|
602
|
+
}
|
|
603
|
+
console.log("");
|
|
604
|
+
if (failed > 0) {
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
// Create command
|
|
614
|
+
program
|
|
615
|
+
.command("create <name>")
|
|
616
|
+
.description("Create a new flow file")
|
|
617
|
+
.option("-d, --dir <directory>", "Directory to create flow in", "tests")
|
|
618
|
+
.action(async (name, options) => {
|
|
619
|
+
const readline = await import("readline");
|
|
620
|
+
// Ensure directory exists
|
|
621
|
+
const dir = options.dir;
|
|
622
|
+
if (!fs.existsSync(dir)) {
|
|
623
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
624
|
+
}
|
|
625
|
+
// Generate filename
|
|
626
|
+
const filename = name.endsWith(".flow") ? name : `${name}.flow`;
|
|
627
|
+
const filepath = path.join(dir, filename);
|
|
628
|
+
if (fs.existsSync(filepath)) {
|
|
629
|
+
console.log(chalk.red(`File already exists: ${filepath}`));
|
|
630
|
+
process.exit(1);
|
|
631
|
+
}
|
|
632
|
+
console.log(chalk.blue(`\nCreating: ${filepath}`));
|
|
633
|
+
console.log(chalk.gray("Enter your test steps (one per line). Empty line to finish.\n"));
|
|
634
|
+
const rl = readline.createInterface({
|
|
635
|
+
input: process.stdin,
|
|
636
|
+
output: process.stdout,
|
|
637
|
+
});
|
|
638
|
+
const lines = [`# ${name}`, ""];
|
|
639
|
+
let lineNum = 1;
|
|
640
|
+
const askLine = () => {
|
|
641
|
+
return new Promise((resolve) => {
|
|
642
|
+
rl.question(chalk.cyan(`${lineNum}. `), (answer) => {
|
|
643
|
+
resolve(answer);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
};
|
|
647
|
+
while (true) {
|
|
648
|
+
const line = await askLine();
|
|
649
|
+
if (line === "") {
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
lines.push(line);
|
|
653
|
+
lineNum++;
|
|
654
|
+
}
|
|
655
|
+
rl.close();
|
|
656
|
+
if (lines.length <= 2) {
|
|
657
|
+
console.log(chalk.yellow("\nNo steps entered. File not created."));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Write file
|
|
661
|
+
fs.writeFileSync(filepath, lines.join("\n") + "\n");
|
|
662
|
+
console.log(chalk.green(`\n✓ Created: ${filepath}`));
|
|
663
|
+
console.log(chalk.gray(` ${lineNum - 1} steps`));
|
|
664
|
+
console.log(chalk.gray(`\nRun with: slapify run ${filepath}`));
|
|
665
|
+
});
|
|
666
|
+
// Generate command - AI-powered flow generation
|
|
667
|
+
program
|
|
668
|
+
.command("generate <prompt>")
|
|
669
|
+
.alias("gen")
|
|
670
|
+
.description("Generate a flow file from a natural language prompt")
|
|
671
|
+
.option("-d, --dir <directory>", "Directory to save flow", "tests")
|
|
672
|
+
.option("--headed", "Show browser while analyzing")
|
|
673
|
+
.action(async (prompt, options) => {
|
|
674
|
+
const configDir = getConfigDir();
|
|
675
|
+
if (!configDir) {
|
|
676
|
+
console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
|
|
677
|
+
process.exit(1);
|
|
678
|
+
}
|
|
679
|
+
const config = loadConfig(configDir);
|
|
680
|
+
const readline = await import("readline");
|
|
681
|
+
console.log(chalk.blue("\n🤖 AI Flow Generator\n"));
|
|
682
|
+
// Step 1: Extract URL from prompt using AI
|
|
683
|
+
let spinner = ora("Analyzing prompt...").start();
|
|
684
|
+
const urlResponse = await generateText({
|
|
685
|
+
model: getModelFromConfig(config.llm),
|
|
686
|
+
prompt: `Extract the URL from this test request. If there's no URL mentioned, respond with "NO_URL".
|
|
687
|
+
|
|
688
|
+
Request: "${prompt}"
|
|
689
|
+
|
|
690
|
+
Respond with ONLY the URL (e.g., https://example.com) or "NO_URL". Nothing else.`,
|
|
691
|
+
maxTokens: 100,
|
|
692
|
+
});
|
|
693
|
+
let url = urlResponse.text.trim();
|
|
694
|
+
spinner.stop();
|
|
695
|
+
// Step 2: If no URL, ask user
|
|
696
|
+
if (url === "NO_URL" || !url.startsWith("http")) {
|
|
697
|
+
const rl = readline.createInterface({
|
|
698
|
+
input: process.stdin,
|
|
699
|
+
output: process.stdout,
|
|
700
|
+
});
|
|
701
|
+
url = await new Promise((resolve) => {
|
|
702
|
+
rl.question(chalk.cyan("Enter the URL to test: "), (answer) => {
|
|
703
|
+
rl.close();
|
|
704
|
+
resolve(answer.trim());
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
if (!url) {
|
|
708
|
+
console.log(chalk.red("No URL provided. Aborting."));
|
|
709
|
+
process.exit(1);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
console.log(chalk.gray(`URL: ${url}\n`));
|
|
713
|
+
// Step 3: Open browser and get page snapshot
|
|
714
|
+
spinner = ora("Opening browser and analyzing page...").start();
|
|
715
|
+
const browser = new BrowserAgent({
|
|
716
|
+
...config.browser,
|
|
717
|
+
headless: !options.headed,
|
|
718
|
+
});
|
|
719
|
+
try {
|
|
720
|
+
await browser.navigate(url);
|
|
721
|
+
await browser.wait(2000); // Wait for page to load
|
|
722
|
+
const snapshot = await browser.snapshot(true);
|
|
723
|
+
const pageTitle = await browser.getTitle();
|
|
724
|
+
spinner.succeed("Page analyzed");
|
|
725
|
+
// Step 4: Generate test steps using AI
|
|
726
|
+
spinner = ora("Generating test steps...").start();
|
|
727
|
+
const generationResponse = await generateText({
|
|
728
|
+
model: getModelFromConfig(config.llm),
|
|
729
|
+
system: `You are a test automation expert. Generate clear, actionable test steps for a .flow file.
|
|
730
|
+
|
|
731
|
+
Rules:
|
|
732
|
+
- Each step should be a single action or verification
|
|
733
|
+
- Use natural language that describes WHAT to do, not HOW
|
|
734
|
+
- Include [Optional] prefix for steps that might not always apply (popups, banners)
|
|
735
|
+
- Include verification steps to confirm actions worked
|
|
736
|
+
- Use "If X appears, do Y" for conditional handling
|
|
737
|
+
- Keep steps concise but clear
|
|
738
|
+
- Start with navigation to the URL
|
|
739
|
+
- Generate a suitable filename (lowercase, hyphenated, no extension)
|
|
740
|
+
|
|
741
|
+
Output format:
|
|
742
|
+
FILENAME: suggested-name
|
|
743
|
+
STEPS:
|
|
744
|
+
Go to <url>
|
|
745
|
+
Step 2 here
|
|
746
|
+
Step 3 here
|
|
747
|
+
...`,
|
|
748
|
+
prompt: `Generate test steps for this request:
|
|
749
|
+
|
|
750
|
+
"${prompt}"
|
|
751
|
+
|
|
752
|
+
Target URL: ${url}
|
|
753
|
+
Page Title: ${pageTitle}
|
|
754
|
+
|
|
755
|
+
Current page structure:
|
|
756
|
+
${snapshot}
|
|
757
|
+
|
|
758
|
+
Generate practical test steps that accomplish the user's goal.`,
|
|
759
|
+
maxTokens: 1500,
|
|
760
|
+
});
|
|
761
|
+
spinner.succeed("Test steps generated");
|
|
762
|
+
// Parse the response
|
|
763
|
+
const responseText = generationResponse.text;
|
|
764
|
+
const filenameMatch = responseText.match(/FILENAME:\s*(.+)/i);
|
|
765
|
+
const stepsMatch = responseText.match(/STEPS:\s*([\s\S]+)/i);
|
|
766
|
+
let filename = filenameMatch
|
|
767
|
+
? filenameMatch[1]
|
|
768
|
+
.trim()
|
|
769
|
+
.replace(/[^a-z0-9-]/gi, "-")
|
|
770
|
+
.toLowerCase()
|
|
771
|
+
: "generated-test";
|
|
772
|
+
if (!filename.endsWith(".flow")) {
|
|
773
|
+
filename += ".flow";
|
|
774
|
+
}
|
|
775
|
+
const steps = stepsMatch
|
|
776
|
+
? stepsMatch[1].trim()
|
|
777
|
+
: responseText.replace(/FILENAME:.+/i, "").trim();
|
|
778
|
+
// Close browser
|
|
779
|
+
await browser.close();
|
|
780
|
+
// Step 5: Show generated steps and confirm
|
|
781
|
+
console.log(chalk.blue("\n━━━ Generated Flow ━━━\n"));
|
|
782
|
+
console.log(chalk.white(steps));
|
|
783
|
+
console.log(chalk.blue("\n━━━━━━━━━━━━━━━━━━━━━━\n"));
|
|
784
|
+
const rl = readline.createInterface({
|
|
785
|
+
input: process.stdin,
|
|
786
|
+
output: process.stdout,
|
|
787
|
+
});
|
|
788
|
+
const confirm = await new Promise((resolve) => {
|
|
789
|
+
rl.question(chalk.cyan(`Save as ${options.dir}/${filename}? (Y/n/edit): `), (answer) => {
|
|
790
|
+
rl.close();
|
|
791
|
+
resolve(answer.trim().toLowerCase());
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
if (confirm === "n" || confirm === "no") {
|
|
795
|
+
console.log(chalk.yellow("Cancelled."));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (confirm === "e" || confirm === "edit") {
|
|
799
|
+
// Let user edit the filename
|
|
800
|
+
const rl2 = readline.createInterface({
|
|
801
|
+
input: process.stdin,
|
|
802
|
+
output: process.stdout,
|
|
803
|
+
});
|
|
804
|
+
filename = await new Promise((resolve) => {
|
|
805
|
+
rl2.question(chalk.cyan("Filename: "), (answer) => {
|
|
806
|
+
rl2.close();
|
|
807
|
+
const name = answer.trim() || filename;
|
|
808
|
+
resolve(name.endsWith(".flow") ? name : name + ".flow");
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
// Step 6: Save the file
|
|
813
|
+
const dir = options.dir;
|
|
814
|
+
if (!fs.existsSync(dir)) {
|
|
815
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
816
|
+
}
|
|
817
|
+
const filepath = path.join(dir, filename);
|
|
818
|
+
const content = `# ${filename
|
|
819
|
+
.replace(".flow", "")
|
|
820
|
+
.replace(/-/g, " ")}\n# Generated from: ${prompt}\n\n${steps}\n`;
|
|
821
|
+
fs.writeFileSync(filepath, content);
|
|
822
|
+
console.log(chalk.green(`\n✓ Saved: ${filepath}`));
|
|
823
|
+
console.log(chalk.gray(`\nRun with: slapify run ${filepath}`));
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
spinner.fail("Error");
|
|
827
|
+
await browser.close();
|
|
828
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
829
|
+
process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
// Fix command - analyze and fix failing tests
|
|
833
|
+
program
|
|
834
|
+
.command("fix <file>")
|
|
835
|
+
.description("Analyze a failing test and suggest/apply fixes")
|
|
836
|
+
.option("--auto", "Automatically apply suggested fixes without confirmation")
|
|
837
|
+
.option("--headed", "Run browser in headed mode for debugging")
|
|
838
|
+
.action(async (file, options) => {
|
|
839
|
+
const configDir = getConfigDir();
|
|
840
|
+
if (!configDir) {
|
|
841
|
+
console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
|
|
842
|
+
process.exit(1);
|
|
843
|
+
}
|
|
844
|
+
if (!fs.existsSync(file)) {
|
|
845
|
+
console.log(chalk.red(`File not found: ${file}`));
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
const config = loadConfig(configDir);
|
|
849
|
+
const credentials = loadCredentials(configDir);
|
|
850
|
+
if (options.headed) {
|
|
851
|
+
config.browser = { ...config.browser, headless: false };
|
|
852
|
+
}
|
|
853
|
+
// Enable screenshots for diagnosis
|
|
854
|
+
config.report = { ...config.report, screenshots: true };
|
|
855
|
+
const readline = await import("readline");
|
|
856
|
+
let spinner = ora("Running test to identify failures...").start();
|
|
857
|
+
try {
|
|
858
|
+
// Step 1: Run the test to see what fails
|
|
859
|
+
const flow = parseFlowFile(file);
|
|
860
|
+
const runner = new TestRunner(config, credentials);
|
|
861
|
+
const failedSteps = [];
|
|
862
|
+
const result = await runner.runFlow(flow, (stepResult) => {
|
|
863
|
+
if (stepResult.status === "failed" && stepResult.error) {
|
|
864
|
+
failedSteps.push({
|
|
865
|
+
step: stepResult.step.text,
|
|
866
|
+
error: stepResult.error,
|
|
867
|
+
line: stepResult.step.line,
|
|
868
|
+
screenshot: stepResult.screenshot,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
if (result.status === "passed") {
|
|
873
|
+
spinner.succeed("Test passed! No fixes needed.");
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
spinner.info(`Found ${failedSteps.length} failing step(s)`);
|
|
877
|
+
if (failedSteps.length === 0) {
|
|
878
|
+
console.log(chalk.yellow("No specific step failures to fix."));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
// Step 2: Read the original flow file
|
|
882
|
+
const originalContent = fs.readFileSync(file, "utf-8");
|
|
883
|
+
const lines = originalContent.split("\n");
|
|
884
|
+
// Step 3: Use AI to analyze and suggest fixes
|
|
885
|
+
spinner = ora("Analyzing failures and generating fixes...").start();
|
|
886
|
+
const failureDetails = failedSteps
|
|
887
|
+
.map((f) => `Line ${f.line}: "${f.step}"\n Error: ${f.error}`)
|
|
888
|
+
.join("\n\n");
|
|
889
|
+
const analysisResponse = await generateText({
|
|
890
|
+
model: getModelFromConfig(config.llm),
|
|
891
|
+
system: `You are a test automation expert. Analyze failing test steps and suggest fixes.
|
|
892
|
+
|
|
893
|
+
Original flow file:
|
|
894
|
+
\`\`\`
|
|
895
|
+
${originalContent}
|
|
896
|
+
\`\`\`
|
|
897
|
+
|
|
898
|
+
Failing steps:
|
|
899
|
+
${failureDetails}
|
|
900
|
+
|
|
901
|
+
Based on the errors, suggest fixes for the flow file. Common issues and fixes:
|
|
902
|
+
1. Element not found → Try more descriptive text, add wait, or make step optional
|
|
903
|
+
2. Timeout → Add explicit wait or increase timeout
|
|
904
|
+
3. Navigation error → Add wait after navigation, or split into smaller steps
|
|
905
|
+
4. Element obscured → Add step to close popup/modal first
|
|
906
|
+
5. Stale element → Add wait for page to stabilize
|
|
907
|
+
|
|
908
|
+
Respond with JSON:
|
|
909
|
+
{
|
|
910
|
+
"analysis": "Brief explanation of what's wrong",
|
|
911
|
+
"fixes": [
|
|
912
|
+
{
|
|
913
|
+
"line": 5,
|
|
914
|
+
"original": "Click the submit button",
|
|
915
|
+
"fixed": "Click the Submit button",
|
|
916
|
+
"reason": "Button text is capitalized"
|
|
917
|
+
}
|
|
918
|
+
],
|
|
919
|
+
"additions": [
|
|
920
|
+
{
|
|
921
|
+
"afterLine": 4,
|
|
922
|
+
"step": "[Optional] Wait for page to load",
|
|
923
|
+
"reason": "Page might still be loading"
|
|
924
|
+
}
|
|
925
|
+
]
|
|
926
|
+
}`,
|
|
927
|
+
prompt: "Analyze the failures and suggest specific fixes.",
|
|
928
|
+
maxTokens: 1500,
|
|
929
|
+
});
|
|
930
|
+
spinner.succeed("Analysis complete");
|
|
931
|
+
// Parse the response
|
|
932
|
+
const jsonMatch = analysisResponse.text.match(/\{[\s\S]*\}/);
|
|
933
|
+
if (!jsonMatch) {
|
|
934
|
+
console.log(chalk.red("Could not parse AI response"));
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const suggestions = JSON.parse(jsonMatch[0]);
|
|
938
|
+
// Step 4: Display suggestions
|
|
939
|
+
console.log(chalk.blue("\n━━━ Analysis ━━━\n"));
|
|
940
|
+
console.log(chalk.white(suggestions.analysis));
|
|
941
|
+
if (suggestions.fixes?.length > 0 || suggestions.additions?.length > 0) {
|
|
942
|
+
console.log(chalk.blue("\n━━━ Suggested Fixes ━━━\n"));
|
|
943
|
+
if (suggestions.fixes?.length > 0) {
|
|
944
|
+
for (const fix of suggestions.fixes) {
|
|
945
|
+
console.log(chalk.yellow(`Line ${fix.line}:`));
|
|
946
|
+
console.log(chalk.red(` - ${fix.original}`));
|
|
947
|
+
console.log(chalk.green(` + ${fix.fixed}`));
|
|
948
|
+
console.log(chalk.gray(` Reason: ${fix.reason}\n`));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (suggestions.additions?.length > 0) {
|
|
952
|
+
console.log(chalk.yellow("New steps to add:"));
|
|
953
|
+
for (const add of suggestions.additions) {
|
|
954
|
+
console.log(chalk.green(` + After line ${add.afterLine}: ${add.step}`));
|
|
955
|
+
console.log(chalk.gray(` Reason: ${add.reason}\n`));
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// Step 5: Apply fixes
|
|
959
|
+
let shouldApply = options.auto;
|
|
960
|
+
if (!shouldApply) {
|
|
961
|
+
const rl = readline.createInterface({
|
|
962
|
+
input: process.stdin,
|
|
963
|
+
output: process.stdout,
|
|
964
|
+
});
|
|
965
|
+
const answer = await new Promise((resolve) => {
|
|
966
|
+
rl.question(chalk.cyan("Apply these fixes? (y/N): "), (answer) => {
|
|
967
|
+
rl.close();
|
|
968
|
+
resolve(answer.trim().toLowerCase());
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
shouldApply = answer === "y" || answer === "yes";
|
|
972
|
+
}
|
|
973
|
+
if (shouldApply) {
|
|
974
|
+
// Apply fixes to lines
|
|
975
|
+
let newLines = [...lines];
|
|
976
|
+
const lineOffsets = new Map(); // Track line number changes
|
|
977
|
+
// Apply modifications first
|
|
978
|
+
if (suggestions.fixes) {
|
|
979
|
+
for (const fix of suggestions.fixes) {
|
|
980
|
+
const lineIdx = fix.line - 1;
|
|
981
|
+
if (lineIdx >= 0 && lineIdx < newLines.length) {
|
|
982
|
+
newLines[lineIdx] = fix.fixed;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
// Apply additions (in reverse order to maintain line numbers)
|
|
987
|
+
if (suggestions.additions) {
|
|
988
|
+
const sortedAdditions = [...suggestions.additions].sort((a, b) => b.afterLine - a.afterLine);
|
|
989
|
+
for (const add of sortedAdditions) {
|
|
990
|
+
const afterIdx = add.afterLine;
|
|
991
|
+
newLines.splice(afterIdx, 0, add.step);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// Write the fixed file
|
|
995
|
+
const newContent = newLines.join("\n");
|
|
996
|
+
// Backup original
|
|
997
|
+
const backupPath = file + ".backup";
|
|
998
|
+
fs.writeFileSync(backupPath, originalContent);
|
|
999
|
+
// Write fixed version
|
|
1000
|
+
fs.writeFileSync(file, newContent);
|
|
1001
|
+
console.log(chalk.green(`\n✓ Fixes applied to ${file}`));
|
|
1002
|
+
console.log(chalk.gray(` Backup saved to ${backupPath}`));
|
|
1003
|
+
console.log(chalk.gray(`\nRun again with: slapify run ${file}`));
|
|
1004
|
+
}
|
|
1005
|
+
else {
|
|
1006
|
+
console.log(chalk.yellow("No changes made."));
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
console.log(chalk.yellow("\nNo automatic fixes suggested."));
|
|
1011
|
+
console.log(chalk.gray("The failures may require manual investigation."));
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
spinner.fail("Error");
|
|
1016
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
// Validate command
|
|
1021
|
+
program
|
|
1022
|
+
.command("validate [files...]")
|
|
1023
|
+
.description("Validate flow files for syntax issues")
|
|
1024
|
+
.action(async (files) => {
|
|
1025
|
+
let flowFiles = [];
|
|
1026
|
+
if (files.length === 0) {
|
|
1027
|
+
const testsDir = path.join(process.cwd(), "tests");
|
|
1028
|
+
if (fs.existsSync(testsDir)) {
|
|
1029
|
+
flowFiles = await findFlowFiles(testsDir);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
flowFiles = files;
|
|
1034
|
+
}
|
|
1035
|
+
if (flowFiles.length === 0) {
|
|
1036
|
+
console.log(chalk.yellow("No .flow files found."));
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
let hasWarnings = false;
|
|
1040
|
+
for (const file of flowFiles) {
|
|
1041
|
+
try {
|
|
1042
|
+
const flow = parseFlowFile(file);
|
|
1043
|
+
const warnings = validateFlowFile(flow);
|
|
1044
|
+
const summary = getFlowSummary(flow);
|
|
1045
|
+
if (warnings.length > 0) {
|
|
1046
|
+
hasWarnings = true;
|
|
1047
|
+
console.log(chalk.yellow(`⚠️ ${file}`));
|
|
1048
|
+
for (const warning of warnings) {
|
|
1049
|
+
console.log(chalk.yellow(` ${warning}`));
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
console.log(chalk.green(`✅ ${file}`));
|
|
1054
|
+
console.log(chalk.gray(` ${summary.totalSteps} steps (${summary.requiredSteps} required, ${summary.optionalSteps} optional)`));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
catch (error) {
|
|
1058
|
+
console.log(chalk.red(`❌ ${file}`));
|
|
1059
|
+
console.log(chalk.red(` ${error.message}`));
|
|
1060
|
+
hasWarnings = true;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (hasWarnings) {
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
// List command
|
|
1068
|
+
program
|
|
1069
|
+
.command("list")
|
|
1070
|
+
.description("List all flow files")
|
|
1071
|
+
.action(async () => {
|
|
1072
|
+
const testsDir = path.join(process.cwd(), "tests");
|
|
1073
|
+
if (!fs.existsSync(testsDir)) {
|
|
1074
|
+
console.log(chalk.yellow("No tests directory found."));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const flowFiles = await findFlowFiles(testsDir);
|
|
1078
|
+
if (flowFiles.length === 0) {
|
|
1079
|
+
console.log(chalk.yellow("No .flow files found."));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
console.log(chalk.blue(`\nFound ${flowFiles.length} flow file(s):\n`));
|
|
1083
|
+
for (const file of flowFiles) {
|
|
1084
|
+
const flow = parseFlowFile(file);
|
|
1085
|
+
const summary = getFlowSummary(flow);
|
|
1086
|
+
const relativePath = path.relative(process.cwd(), file);
|
|
1087
|
+
console.log(` ${chalk.white(relativePath)}`);
|
|
1088
|
+
console.log(chalk.gray(` ${summary.totalSteps} steps (${summary.requiredSteps} required, ${summary.optionalSteps} optional)`));
|
|
1089
|
+
}
|
|
1090
|
+
console.log("");
|
|
1091
|
+
});
|
|
1092
|
+
// Credentials command
|
|
1093
|
+
program
|
|
1094
|
+
.command("credentials")
|
|
1095
|
+
.description("List configured credential profiles")
|
|
1096
|
+
.action(() => {
|
|
1097
|
+
const configDir = getConfigDir();
|
|
1098
|
+
if (!configDir) {
|
|
1099
|
+
console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
|
|
1100
|
+
process.exit(1);
|
|
1101
|
+
}
|
|
1102
|
+
const credentials = loadCredentials(configDir);
|
|
1103
|
+
const profiles = Object.keys(credentials.profiles);
|
|
1104
|
+
if (profiles.length === 0) {
|
|
1105
|
+
console.log(chalk.yellow("No credential profiles configured."));
|
|
1106
|
+
console.log(chalk.gray("Edit .slapify/credentials.yaml to add profiles."));
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
console.log(chalk.blue(`\nConfigured credential profiles:\n`));
|
|
1110
|
+
for (const name of profiles) {
|
|
1111
|
+
const profile = credentials.profiles[name];
|
|
1112
|
+
console.log(` ${chalk.white(name)} (${profile.type})`);
|
|
1113
|
+
if (profile.username) {
|
|
1114
|
+
console.log(chalk.gray(` username: ${profile.username}`));
|
|
1115
|
+
}
|
|
1116
|
+
if (profile.email) {
|
|
1117
|
+
console.log(chalk.gray(` email: ${profile.email}`));
|
|
1118
|
+
}
|
|
1119
|
+
if (profile.totp_secret) {
|
|
1120
|
+
console.log(chalk.gray(` 2FA: TOTP configured`));
|
|
1121
|
+
}
|
|
1122
|
+
if (profile.fixed_otp) {
|
|
1123
|
+
console.log(chalk.gray(` 2FA: Fixed OTP configured`));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
console.log("");
|
|
1127
|
+
});
|
|
1128
|
+
// Interactive mode
|
|
1129
|
+
program
|
|
1130
|
+
.command("interactive [url]")
|
|
1131
|
+
.alias("i")
|
|
1132
|
+
.description("Run steps interactively")
|
|
1133
|
+
.option("--headed", "Run browser in headed mode")
|
|
1134
|
+
.action(async (url, options) => {
|
|
1135
|
+
console.log(chalk.blue("\n🧪 Slapify Interactive Mode"));
|
|
1136
|
+
console.log(chalk.gray('Type test steps and press Enter to execute. Type "exit" to quit.\n'));
|
|
1137
|
+
const configDir = getConfigDir();
|
|
1138
|
+
if (!configDir) {
|
|
1139
|
+
console.log(chalk.red('No .slapify directory found. Run "slapify init" first.'));
|
|
1140
|
+
process.exit(1);
|
|
1141
|
+
}
|
|
1142
|
+
const config = loadConfig(configDir);
|
|
1143
|
+
const credentials = loadCredentials(configDir);
|
|
1144
|
+
if (options.headed) {
|
|
1145
|
+
config.browser = { ...config.browser, headless: false };
|
|
1146
|
+
}
|
|
1147
|
+
const runner = new TestRunner(config, credentials);
|
|
1148
|
+
if (url) {
|
|
1149
|
+
console.log(chalk.gray(`Navigating to ${url}...`));
|
|
1150
|
+
// Would navigate here
|
|
1151
|
+
}
|
|
1152
|
+
// Simple readline interface
|
|
1153
|
+
const readline = await import("readline");
|
|
1154
|
+
const rl = readline.createInterface({
|
|
1155
|
+
input: process.stdin,
|
|
1156
|
+
output: process.stdout,
|
|
1157
|
+
});
|
|
1158
|
+
const prompt = () => {
|
|
1159
|
+
rl.question(chalk.cyan("> "), async (input) => {
|
|
1160
|
+
const trimmed = input.trim();
|
|
1161
|
+
if (trimmed.toLowerCase() === "exit" ||
|
|
1162
|
+
trimmed.toLowerCase() === "quit") {
|
|
1163
|
+
console.log(chalk.gray("\nGoodbye!"));
|
|
1164
|
+
rl.close();
|
|
1165
|
+
process.exit(0);
|
|
1166
|
+
}
|
|
1167
|
+
if (!trimmed) {
|
|
1168
|
+
prompt();
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
// Execute the step
|
|
1172
|
+
console.log(chalk.gray(`Executing: ${trimmed}`));
|
|
1173
|
+
// Would execute step here
|
|
1174
|
+
console.log(chalk.green("✓ Done"));
|
|
1175
|
+
prompt();
|
|
1176
|
+
});
|
|
1177
|
+
};
|
|
1178
|
+
prompt();
|
|
1179
|
+
});
|
|
1180
|
+
program.parse();
|
|
1181
|
+
//# sourceMappingURL=cli.js.map
|