@testspectra/cli 1.0.23 → 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 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,5 @@
1
+ export interface AddCommandOptions {
2
+ e2eFolder?: string;
3
+ force?: boolean;
4
+ }
5
+ export declare function addCommand(moduleNameArg?: string, options?: AddCommandOptions): Promise<void>;
@@ -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
+ }
@@ -382,12 +382,9 @@ export async function initCommand(options = {}) {
382
382
  fs.unlinkSync(localConfig);
383
383
  featureE2eDirs.push(targetE2eDir);
384
384
  }
385
- // 5. Ensure pnpm-workspace.yaml includes shared library and all feature E2E directories
385
+ // 5. Ensure pnpm-workspace.yaml includes shared library (feature modules are already part of existing workspace globs)
386
386
  const pnpmWorkspaceFilePath = path.join(cwd, "pnpm-workspace.yaml");
387
387
  ensurePnpmWorkspaceIncludes(pnpmWorkspaceFilePath, sharedTestingRelPath);
388
- for (const e2eDir of featureE2eDirs) {
389
- ensurePnpmWorkspaceIncludes(pnpmWorkspaceFilePath, path.relative(cwd, e2eDir));
390
- }
391
388
  // 6. Update Root Solution tsconfig.json references
392
389
  const rootTsConfigPath = path.join(cwd, "tsconfig.json");
393
390
  const references = [];
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)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testspectra/cli",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "TestSpectra Zero-Config Cross-Platform Test Runner CLI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",