@testspectra/cli 1.0.24 → 1.0.25
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 +1 -0
- package/dist/commands/add.d.ts +5 -0
- package/dist/commands/add.js +372 -0
- package/dist/index.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -162,6 +162,7 @@ export default defineConfig({
|
|
|
162
162
|
| Command | Description |
|
|
163
163
|
| :--- | :--- |
|
|
164
164
|
| `spectra init` / `npx @testspectra/cli init` | Interactive project setup. Auto-detects standalone or Nx Monorepos, discovers workspace packages from `pnpm-workspace.yaml`, and scaffolds centralized/distributed configurations. |
|
|
165
|
+
| `spectra add [module]` | Adds a new TestSpectra E2E testing module to an existing workspace project or creates a new feature testing suite. |
|
|
165
166
|
| `spectra run [spec]` | Automatically updates ambient declarations and invokes the native Rust test runner to execute the test suite. |
|
|
166
167
|
| `spectra doctor` | Verifies local environment prerequisites (ADB, Java, Chrome, Bun, Node). |
|
|
167
168
|
| `spectra devices` | Lists connected Android/iOS devices and local browsers. |
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { TypeGenerator } from "../types/generator.js";
|
|
7
|
+
function parsePnpmWorkspaceGlobs(content) {
|
|
8
|
+
const lines = content.split("\n");
|
|
9
|
+
const globs = [];
|
|
10
|
+
let inPackages = false;
|
|
11
|
+
for (const line of lines) {
|
|
12
|
+
const trimmed = line.trim();
|
|
13
|
+
if (trimmed.startsWith("packages:")) {
|
|
14
|
+
inPackages = true;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (inPackages) {
|
|
18
|
+
if (trimmed.startsWith("-")) {
|
|
19
|
+
const item = trimmed.replace(/^-\s*['"]?/, "").replace(/['"]?\s*$/, "");
|
|
20
|
+
if (item)
|
|
21
|
+
globs.push(item);
|
|
22
|
+
}
|
|
23
|
+
else if (trimmed && !trimmed.startsWith("#")) {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return globs;
|
|
29
|
+
}
|
|
30
|
+
function scanDirectoriesForProjects(cwd, patterns) {
|
|
31
|
+
const foundProjects = [];
|
|
32
|
+
const visited = new Set();
|
|
33
|
+
function search(dir, currentDepth, maxDepth) {
|
|
34
|
+
if (currentDepth > maxDepth || !fs.existsSync(dir))
|
|
35
|
+
return;
|
|
36
|
+
const base = path.basename(dir);
|
|
37
|
+
if (base === "node_modules" || base === "dist" || base === ".testspectra" || base === ".git" || base === "shared") {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const hasPkg = fs.existsSync(path.join(dir, "package.json"));
|
|
41
|
+
const hasNx = fs.existsSync(path.join(dir, "project.json"));
|
|
42
|
+
if ((hasPkg || hasNx) && dir !== cwd) {
|
|
43
|
+
const rel = path.relative(cwd, dir);
|
|
44
|
+
if (!visited.has(rel)) {
|
|
45
|
+
visited.add(rel);
|
|
46
|
+
let name = path.basename(dir);
|
|
47
|
+
if (hasPkg) {
|
|
48
|
+
try {
|
|
49
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8"));
|
|
50
|
+
if (pkg.name)
|
|
51
|
+
name = pkg.name;
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
}
|
|
55
|
+
else if (hasNx) {
|
|
56
|
+
try {
|
|
57
|
+
const nxJson = JSON.parse(fs.readFileSync(path.join(dir, "project.json"), "utf-8"));
|
|
58
|
+
if (nxJson.name)
|
|
59
|
+
name = nxJson.name;
|
|
60
|
+
}
|
|
61
|
+
catch { }
|
|
62
|
+
}
|
|
63
|
+
foundProjects.push({
|
|
64
|
+
name,
|
|
65
|
+
relPath: rel,
|
|
66
|
+
absPath: dir,
|
|
67
|
+
hasNxProject: hasNx,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
search(path.join(dir, entry.name), currentDepth + 1, maxDepth);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
if (patterns.length > 0) {
|
|
83
|
+
for (const pat of patterns) {
|
|
84
|
+
const cleanPat = pat.replace(/\/\*\*?$/, "").replace(/\/\*$/, "");
|
|
85
|
+
const searchRoot = path.join(cwd, cleanPat);
|
|
86
|
+
if (fs.existsSync(searchRoot)) {
|
|
87
|
+
const stat = fs.statSync(searchRoot);
|
|
88
|
+
if (stat.isDirectory()) {
|
|
89
|
+
search(searchRoot, 1, 3);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
for (const fallback of ["packages", "modules", "apps", "libs", "src"]) {
|
|
96
|
+
const fbPath = path.join(cwd, fallback);
|
|
97
|
+
if (fs.existsSync(fbPath)) {
|
|
98
|
+
search(fbPath, 1, 3);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return foundProjects;
|
|
103
|
+
}
|
|
104
|
+
export async function addCommand(moduleNameArg, options = {}) {
|
|
105
|
+
const cwd = process.cwd();
|
|
106
|
+
// 1. Locate workspace root
|
|
107
|
+
let rootDir = cwd;
|
|
108
|
+
let cur = cwd;
|
|
109
|
+
while (cur !== path.dirname(cur)) {
|
|
110
|
+
if (fs.existsSync(path.join(cur, "spectra.config.ts")) ||
|
|
111
|
+
fs.existsSync(path.join(cur, "pnpm-workspace.yaml")) ||
|
|
112
|
+
fs.existsSync(path.join(cur, "nx.json"))) {
|
|
113
|
+
rootDir = cur;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
cur = path.dirname(cur);
|
|
117
|
+
}
|
|
118
|
+
p.intro(chalk.bold.cyan("✨ TestSpectra Module Generator"));
|
|
119
|
+
// Check if we are in a monorepo
|
|
120
|
+
const pnpmWorkspacePath = path.join(rootDir, "pnpm-workspace.yaml");
|
|
121
|
+
const nxJsonPath = path.join(rootDir, "nx.json");
|
|
122
|
+
const isMonorepo = fs.existsSync(pnpmWorkspacePath) || fs.existsSync(nxJsonPath);
|
|
123
|
+
// 2. Resolve template directory
|
|
124
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
125
|
+
const __dirname = path.dirname(__filename);
|
|
126
|
+
let templateDir = path.resolve(__dirname, "../templates/nx");
|
|
127
|
+
if (!fs.existsSync(templateDir)) {
|
|
128
|
+
templateDir = path.resolve(__dirname, "../../templates/nx");
|
|
129
|
+
}
|
|
130
|
+
if (!fs.existsSync(templateDir)) {
|
|
131
|
+
templateDir = path.resolve(__dirname, "../../../templates/nx");
|
|
132
|
+
}
|
|
133
|
+
const sampleE2eSrc = path.join(templateDir, "modules/auth/e2e");
|
|
134
|
+
if (!fs.existsSync(sampleE2eSrc)) {
|
|
135
|
+
p.cancel(chalk.red(`Template directory not found at: ${sampleE2eSrc}`));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// 3. Resolve CLI dependency version
|
|
139
|
+
let cliVersion = "^1.0.24";
|
|
140
|
+
try {
|
|
141
|
+
const cliPackageJsonPath = path.resolve(__dirname, "../../package.json");
|
|
142
|
+
if (fs.existsSync(cliPackageJsonPath)) {
|
|
143
|
+
const cliPkg = JSON.parse(fs.readFileSync(cliPackageJsonPath, "utf-8"));
|
|
144
|
+
if (cliPkg.version) {
|
|
145
|
+
cliVersion = `^${cliPkg.version}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
let isInternalWorkspace = false;
|
|
151
|
+
cur = rootDir;
|
|
152
|
+
while (cur !== path.dirname(cur)) {
|
|
153
|
+
if (fs.existsSync(path.join(cur, "pnpm-workspace.yaml"))) {
|
|
154
|
+
try {
|
|
155
|
+
const wsContent = fs.readFileSync(path.join(cur, "pnpm-workspace.yaml"), "utf-8");
|
|
156
|
+
if (wsContent.includes("cli")) {
|
|
157
|
+
isInternalWorkspace = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
}
|
|
163
|
+
cur = path.dirname(cur);
|
|
164
|
+
}
|
|
165
|
+
const cliDepVersion = isInternalWorkspace ? "workspace:*" : cliVersion;
|
|
166
|
+
function copyRecursive(src, dest, replacements = {}) {
|
|
167
|
+
const base = path.basename(src);
|
|
168
|
+
if (base === "node_modules" || base === "dist" || base === ".testspectra" || base === ".git") {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const stat = fs.statSync(src);
|
|
172
|
+
if (stat.isDirectory()) {
|
|
173
|
+
if (!fs.existsSync(dest)) {
|
|
174
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
const entries = fs.readdirSync(src);
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
copyRecursive(path.join(src, entry), path.join(dest, entry), replacements);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
if (fs.existsSync(dest) && !options.force) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let content = fs.readFileSync(src, "utf-8");
|
|
186
|
+
for (const [k, v] of Object.entries(replacements)) {
|
|
187
|
+
content = content.replaceAll(k, v);
|
|
188
|
+
}
|
|
189
|
+
if (dest.endsWith("package.json")) {
|
|
190
|
+
try {
|
|
191
|
+
const pkg = JSON.parse(content);
|
|
192
|
+
if (pkg.devDependencies && pkg.devDependencies["@testspectra/cli"]) {
|
|
193
|
+
pkg.devDependencies["@testspectra/cli"] = cliDepVersion;
|
|
194
|
+
}
|
|
195
|
+
if (pkg.dependencies && pkg.dependencies["@testspectra/cli"]) {
|
|
196
|
+
pkg.dependencies["@testspectra/cli"] = cliDepVersion;
|
|
197
|
+
}
|
|
198
|
+
content = JSON.stringify(pkg, null, 2);
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
}
|
|
202
|
+
fs.writeFileSync(dest, content, "utf-8");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// If in a Monorepo
|
|
206
|
+
if (isMonorepo) {
|
|
207
|
+
let pnpmPatterns = [];
|
|
208
|
+
if (fs.existsSync(pnpmWorkspacePath)) {
|
|
209
|
+
try {
|
|
210
|
+
pnpmPatterns = parsePnpmWorkspaceGlobs(fs.readFileSync(pnpmWorkspacePath, "utf-8"));
|
|
211
|
+
}
|
|
212
|
+
catch { }
|
|
213
|
+
}
|
|
214
|
+
const detectedProjects = scanDirectoriesForProjects(rootDir, pnpmPatterns);
|
|
215
|
+
let targetProjectPath = "";
|
|
216
|
+
let featureName = "";
|
|
217
|
+
if (moduleNameArg) {
|
|
218
|
+
// Find if moduleNameArg matches an existing detected package
|
|
219
|
+
const match = detectedProjects.find((p) => p.name === moduleNameArg || p.relPath === moduleNameArg || path.basename(p.relPath) === moduleNameArg);
|
|
220
|
+
if (match) {
|
|
221
|
+
targetProjectPath = match.relPath;
|
|
222
|
+
featureName = path.basename(match.relPath);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
targetProjectPath = moduleNameArg;
|
|
226
|
+
featureName = path.basename(moduleNameArg);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const mode = await p.select({
|
|
231
|
+
message: "How would you like to add the TestSpectra module?",
|
|
232
|
+
options: [
|
|
233
|
+
{
|
|
234
|
+
label: "Add E2E to an existing workspace project / feature",
|
|
235
|
+
value: "existing",
|
|
236
|
+
hint: "Attach E2E test suite to a detected workspace package",
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
label: "Create a new custom feature / module path",
|
|
240
|
+
value: "custom",
|
|
241
|
+
hint: "Specify a custom path e.g. packages/features/billing",
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
if (p.isCancel(mode)) {
|
|
246
|
+
p.cancel("Operation cancelled.");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (mode === "existing" && detectedProjects.length > 0) {
|
|
250
|
+
const choice = await p.select({
|
|
251
|
+
message: "Select workspace project to add TestSpectra E2E testing to:",
|
|
252
|
+
options: detectedProjects.map((proj) => ({
|
|
253
|
+
value: proj.relPath,
|
|
254
|
+
label: `${proj.relPath} (${proj.name})`,
|
|
255
|
+
hint: proj.hasNxProject ? "Nx project.json" : "package.json",
|
|
256
|
+
})),
|
|
257
|
+
});
|
|
258
|
+
if (p.isCancel(choice)) {
|
|
259
|
+
p.cancel("Operation cancelled.");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
targetProjectPath = choice;
|
|
263
|
+
featureName = path.basename(targetProjectPath);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const customPath = await p.text({
|
|
267
|
+
message: "Enter path for the new feature module (e.g. packages/features/payment):",
|
|
268
|
+
placeholder: "packages/features/new-feature",
|
|
269
|
+
validate: (val) => {
|
|
270
|
+
if (!val || !val.trim())
|
|
271
|
+
return "Path cannot be empty";
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
if (p.isCancel(customPath)) {
|
|
275
|
+
p.cancel("Operation cancelled.");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
targetProjectPath = customPath.trim();
|
|
279
|
+
featureName = path.basename(targetProjectPath);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const e2eFolderName = options.e2eFolder || "e2e";
|
|
283
|
+
const targetE2eDir = path.join(rootDir, targetProjectPath, e2eFolderName);
|
|
284
|
+
const e2eProjectName = `${featureName}-${e2eFolderName}`;
|
|
285
|
+
if (fs.existsSync(targetE2eDir) && !options.force) {
|
|
286
|
+
p.log.warn(chalk.yellow(`E2E module already exists at ./${path.relative(rootDir, targetE2eDir)}. Use --force to overwrite.`));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const s = p.spinner();
|
|
290
|
+
s.start(`Scaffolding ${e2eProjectName} in ./${path.relative(rootDir, targetE2eDir)}...`);
|
|
291
|
+
copyRecursive(sampleE2eSrc, targetE2eDir, {
|
|
292
|
+
"auth-e2e": e2eProjectName,
|
|
293
|
+
"modules/auth/e2e": path.relative(rootDir, targetE2eDir).replace(/\\/g, "/"),
|
|
294
|
+
"scope:auth": `scope:${featureName}`,
|
|
295
|
+
});
|
|
296
|
+
// Adjust project.json
|
|
297
|
+
const projJsonPath = path.join(targetE2eDir, "project.json");
|
|
298
|
+
if (fs.existsSync(projJsonPath)) {
|
|
299
|
+
try {
|
|
300
|
+
const pObj = JSON.parse(fs.readFileSync(projJsonPath, "utf-8"));
|
|
301
|
+
pObj.name = e2eProjectName;
|
|
302
|
+
pObj.sourceRoot = path.relative(rootDir, targetE2eDir).replace(/\\/g, "/");
|
|
303
|
+
pObj.targets.e2e.options.cwd = path.relative(rootDir, targetE2eDir).replace(/\\/g, "/");
|
|
304
|
+
pObj.targets["type-check"].options.cwd = path.relative(rootDir, targetE2eDir).replace(/\\/g, "/");
|
|
305
|
+
pObj.tags = [`scope:${featureName}`, "type:e2e"];
|
|
306
|
+
fs.writeFileSync(projJsonPath, JSON.stringify(pObj, null, 2), "utf-8");
|
|
307
|
+
}
|
|
308
|
+
catch { }
|
|
309
|
+
}
|
|
310
|
+
// Ensure no local spectra.config.ts (centralized in root)
|
|
311
|
+
const localConfig = path.join(targetE2eDir, "spectra.config.ts");
|
|
312
|
+
if (fs.existsSync(localConfig))
|
|
313
|
+
fs.unlinkSync(localConfig);
|
|
314
|
+
// Update Root Solution tsconfig.json references
|
|
315
|
+
const rootTsConfigPath = path.join(rootDir, "tsconfig.json");
|
|
316
|
+
if (fs.existsSync(rootTsConfigPath)) {
|
|
317
|
+
try {
|
|
318
|
+
const rootTsConfig = JSON.parse(fs.readFileSync(rootTsConfigPath, "utf-8"));
|
|
319
|
+
if (!rootTsConfig.references)
|
|
320
|
+
rootTsConfig.references = [];
|
|
321
|
+
const relRef = `./${path.relative(rootDir, targetE2eDir).replace(/\\/g, "/")}`;
|
|
322
|
+
if (!rootTsConfig.references.some((r) => r.path === relRef)) {
|
|
323
|
+
rootTsConfig.references.push({ path: relRef });
|
|
324
|
+
fs.writeFileSync(rootTsConfigPath, JSON.stringify(rootTsConfig, null, 2), "utf-8");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch { }
|
|
328
|
+
}
|
|
329
|
+
// Generate Ambient Types for root and new module
|
|
330
|
+
TypeGenerator.writeDeclarationFiles(rootDir);
|
|
331
|
+
TypeGenerator.writeDeclarationFiles(targetE2eDir);
|
|
332
|
+
s.stop(`Module ${e2eProjectName} added successfully!`);
|
|
333
|
+
p.log.success(chalk.green(`Directory: ./${path.relative(rootDir, targetE2eDir)}`));
|
|
334
|
+
p.log.success(chalk.green(`Nx Target: ${e2eProjectName}:e2e`));
|
|
335
|
+
p.log.message(chalk.cyan(`Run tests: pnpm nx run ${e2eProjectName}:e2e`));
|
|
336
|
+
p.outro(chalk.bold.green("🎉 New TestSpectra module ready!"));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// Standalone mode: adding a new spec suite in specs/<ModuleName>
|
|
340
|
+
let targetModule = moduleNameArg;
|
|
341
|
+
if (!targetModule) {
|
|
342
|
+
const input = await p.text({
|
|
343
|
+
message: "Enter name of the new test suite/module (e.g. Payment, Profile):",
|
|
344
|
+
placeholder: "Checkout",
|
|
345
|
+
validate: (val) => {
|
|
346
|
+
if (!val || !val.trim())
|
|
347
|
+
return "Module name cannot be empty";
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
if (p.isCancel(input)) {
|
|
351
|
+
p.cancel("Operation cancelled.");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
targetModule = input.trim();
|
|
355
|
+
}
|
|
356
|
+
const specDir = path.join(cwd, "specs", targetModule, "TC-01");
|
|
357
|
+
const poDir = path.join(cwd, "page-objects", `${targetModule}Page`);
|
|
358
|
+
if (!fs.existsSync(specDir))
|
|
359
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
360
|
+
if (!fs.existsSync(poDir))
|
|
361
|
+
fs.mkdirSync(poDir, { recursive: true });
|
|
362
|
+
// Create starter page object
|
|
363
|
+
const poContent = `class ${targetModule}Page {\n get mainTitle() {\n return $("h1");\n }\n\n async open() {\n await Spectra.navigate("/${targetModule.toLowerCase()}");\n }\n}\n\nexport default new ${targetModule}Page();\n`;
|
|
364
|
+
fs.writeFileSync(path.join(poDir, "common.ts"), poContent, "utf-8");
|
|
365
|
+
// Create starter test spec
|
|
366
|
+
const specContent = `describe("${targetModule} Test Suite", () => {\n it("should verify ${targetModule} page components", async () => {\n await ${targetModule}Page.open();\n await ${targetModule}Page.mainTitle.shouldBeVisible();\n });\n});\n`;
|
|
367
|
+
fs.writeFileSync(path.join(specDir, "web.test.ts"), specContent, "utf-8");
|
|
368
|
+
TypeGenerator.writeDeclarationFiles(cwd);
|
|
369
|
+
p.log.success(chalk.green(`Created Page Object: ./page-objects/${targetModule}Page/common.ts`));
|
|
370
|
+
p.log.success(chalk.green(`Created Test Spec: ./specs/${targetModule}/TC-01/web.test.ts`));
|
|
371
|
+
p.outro(chalk.bold.green(`🎉 Suite ${targetModule} added successfully!`));
|
|
372
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { addCommand } from "./commands/add.js";
|
|
2
3
|
import { devicesCommand } from "./commands/devices.js";
|
|
3
4
|
import { doctorCommand } from "./commands/doctor.js";
|
|
4
5
|
import { initCommand } from "./commands/init.js";
|
|
@@ -20,6 +21,12 @@ export function createCliProgram() {
|
|
|
20
21
|
.option("-t, --template <template>", "Project template: default, nx")
|
|
21
22
|
.option("-f, --force", "Overwrite existing configuration if present")
|
|
22
23
|
.action(initCommand);
|
|
24
|
+
program
|
|
25
|
+
.command("add [module]")
|
|
26
|
+
.description("Add a new TestSpectra E2E testing module to workspace or standalone project")
|
|
27
|
+
.option("-e, --e2e-folder <name>", "Name of E2E sub-folder (default: e2e)")
|
|
28
|
+
.option("-f, --force", "Overwrite existing module files if present")
|
|
29
|
+
.action(addCommand);
|
|
23
30
|
program
|
|
24
31
|
.command("doctor")
|
|
25
32
|
.description("Verify local environment prerequisites (ADB, Java, Chrome, Bun, Node)")
|