@testspectra/cli 1.2.12 → 1.2.14

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.
Files changed (75) hide show
  1. package/dist/commands/init.d.ts +3 -1
  2. package/dist/commands/init.js +67 -5
  3. package/dist/commands/test.d.ts +4 -0
  4. package/dist/commands/test.js +104 -1
  5. package/dist/config/schema.d.ts +24 -0
  6. package/dist/generator/harness-generator.d.ts +13 -0
  7. package/dist/generator/harness-generator.js +51 -0
  8. package/dist/generator/index.d.ts +1 -0
  9. package/dist/generator/index.js +1 -0
  10. package/dist/generator/page-object-declarations.d.ts +27 -0
  11. package/dist/generator/page-object-declarations.js +142 -0
  12. package/dist/generator/type-generator.d.ts +8 -3
  13. package/dist/generator/type-generator.js +51 -89
  14. package/dist/index.js +3 -1
  15. package/dist/linter/spec-linter.js +34 -4
  16. package/dist/refactor/refactor-engine.js +62 -34
  17. package/dist/utils/dev-server-health.d.ts +10 -0
  18. package/dist/utils/dev-server-health.js +65 -0
  19. package/dist/utils/dev-server-manager.d.ts +5 -0
  20. package/dist/utils/dev-server-manager.js +25 -0
  21. package/dist/utils/update-notifier.d.ts +17 -0
  22. package/dist/utils/update-notifier.js +47 -3
  23. package/package.json +9 -8
  24. package/templates/default/package.json +3 -3
  25. package/templates/nx/package.json +3 -3
  26. package/templates/react-component/package.json +3 -3
  27. package/templates/react-native-component/.testspectra/harness/App.tsx +15 -0
  28. package/templates/react-native-component/.testspectra/harness/index.js +6 -0
  29. package/templates/react-native-component/.testspectra/tsconfig/android.json +105 -0
  30. package/templates/react-native-component/.testspectra/tsconfig/android.tsbuildinfo +1 -0
  31. package/templates/react-native-component/.testspectra/tsconfig/common.json +164 -0
  32. package/templates/react-native-component/.testspectra/tsconfig/common.tsbuildinfo +1 -0
  33. package/templates/react-native-component/.testspectra/tsconfig/ios.json +105 -0
  34. package/templates/react-native-component/.testspectra/tsconfig/ios.tsbuildinfo +1 -0
  35. package/templates/react-native-component/.testspectra/tsconfig/mobile.json +137 -0
  36. package/templates/react-native-component/.testspectra/tsconfig/mobile.tsbuildinfo +1 -0
  37. package/templates/react-native-component/.testspectra/tsconfig/web.json +137 -0
  38. package/templates/react-native-component/.testspectra/tsconfig/web.tsbuildinfo +1 -0
  39. package/templates/react-native-component/.testspectra/tsconfig.json +29 -0
  40. package/templates/react-native-component/.testspectra/types/android.d.ts +22 -0
  41. package/templates/react-native-component/.testspectra/types/common.d.ts +19 -0
  42. package/templates/react-native-component/.testspectra/types/env.d.ts +6 -0
  43. package/templates/react-native-component/.testspectra/types/fixtures.d.ts +6 -0
  44. package/templates/react-native-component/.testspectra/types/index.d.ts +1 -0
  45. package/templates/react-native-component/.testspectra/types/ios.d.ts +22 -0
  46. package/templates/react-native-component/.testspectra/types/mobile.d.ts +22 -0
  47. package/templates/react-native-component/.testspectra/types/plugins/android.d.ts +2 -0
  48. package/templates/react-native-component/.testspectra/types/plugins/common.d.ts +2 -0
  49. package/templates/react-native-component/.testspectra/types/plugins/ios.d.ts +2 -0
  50. package/templates/react-native-component/.testspectra/types/plugins/mobile.d.ts +2 -0
  51. package/templates/react-native-component/.testspectra/types/plugins/web.d.ts +2 -0
  52. package/templates/react-native-component/.testspectra/types/web.d.ts +18 -0
  53. package/templates/react-native-component/.vscode/extensions.json +5 -0
  54. package/templates/react-native-component/.vscode/launch.json +32 -0
  55. package/templates/react-native-component/CLAUDE.md +13 -0
  56. package/templates/react-native-component/README.md +40 -0
  57. package/templates/react-native-component/node_modules/.bin/react-native +43 -0
  58. package/templates/react-native-component/node_modules/.bin/spectra +43 -0
  59. package/templates/react-native-component/node_modules/.bin/tsc +43 -0
  60. package/templates/react-native-component/node_modules/.bin/tsserver +43 -0
  61. package/templates/react-native-component/package.json +25 -0
  62. package/templates/react-native-component/page-objects/BadgeComponent/mobile.ts +13 -0
  63. package/templates/react-native-component/page-objects/ButtonComponent/mobile.ts +13 -0
  64. package/templates/react-native-component/page-objects/InputComponent/mobile.ts +8 -0
  65. package/templates/react-native-component/specs/BadgeComponent/TC-0002-badge-render/mobile.test.tsx +9 -0
  66. package/templates/react-native-component/specs/BadgeComponent/TC-0002-badge-render/spec.md +10 -0
  67. package/templates/react-native-component/specs/ButtonComponent/TC-0001-button-press/mobile.test.tsx +21 -0
  68. package/templates/react-native-component/specs/ButtonComponent/TC-0001-button-press/spec.md +10 -0
  69. package/templates/react-native-component/specs/InputComponent/TC-0003-input-typing/mobile.test.tsx +19 -0
  70. package/templates/react-native-component/specs/InputComponent/TC-0003-input-typing/spec.md +10 -0
  71. package/templates/react-native-component/spectra.config.ts +10 -0
  72. package/templates/react-native-component/src/components/Badge/Badge.tsx +56 -0
  73. package/templates/react-native-component/src/components/Button/Button.tsx +63 -0
  74. package/templates/react-native-component/src/components/Input/Input.tsx +56 -0
  75. package/templates/react-native-component/tsconfig.json +8 -0
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { ConfigLoader } from '../config/loader.js';
5
5
  import { TsConfigGenerator } from './tsconfig-generator.js';
6
+ import { collectPageObjectEntries, renderPageObjectDeclarations } from './page-object-declarations.js';
6
7
  export const PLATFORM_HIERARCHY = {
7
8
  web: ['web', 'common'],
8
9
  android: ['android', 'mobile', 'common'],
@@ -178,23 +179,27 @@ export function discoverEntityScopes(cwd) {
178
179
  fallbackScan(cwd, 5);
179
180
  return scopes;
180
181
  }
181
- /**
182
- * Detects a component-testing project (`@testspectra/react`'s Vitest browser-mode runner), via
183
- * the same signal `cli/src/commands/test.ts` uses to gate `spectra test` itself. Used to augment
184
- * the generated `Spectra` ambient type with `.mount()`, which only exists in that runtime.
185
- */
186
- export function isComponentTestingProject(cwd) {
182
+ export function detectComponentTesting(cwd) {
187
183
  try {
188
184
  const pkgPath = path.join(cwd, 'package.json');
189
185
  if (!fs.existsSync(pkgPath))
190
- return false;
186
+ return { isComponent: false, isWebComponent: false, isMobileComponent: false };
191
187
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
192
- return Boolean(pkg.dependencies?.['@testspectra/react'] || pkg.devDependencies?.['@testspectra/react']);
188
+ const isWebComponent = Boolean(pkg.dependencies?.['@testspectra/react'] || pkg.devDependencies?.['@testspectra/react']);
189
+ const isMobileComponent = Boolean(pkg.dependencies?.['@testspectra/react-native'] || pkg.devDependencies?.['@testspectra/react-native']);
190
+ return {
191
+ isComponent: isWebComponent || isMobileComponent,
192
+ isWebComponent,
193
+ isMobileComponent,
194
+ };
193
195
  }
194
196
  catch {
195
- return false;
197
+ return { isComponent: false, isWebComponent: false, isMobileComponent: false };
196
198
  }
197
199
  }
200
+ export function isComponentTestingProject(cwd) {
201
+ return detectComponentTesting(cwd).isComponent;
202
+ }
198
203
  /** Platform stems a plugin may expose as `exports` subpaths. */
199
204
  const PLUGIN_STEMS = ['web', 'android', 'ios', 'mobile', 'common'];
200
205
  /**
@@ -236,15 +241,23 @@ function resolvePluginPackageJson(cwd, specifier) {
236
241
  }
237
242
  return null;
238
243
  }
244
+ // A subpath specifier (`@scope/pkg/plugin`) resolves to its owning package, so its root
245
+ // augmentations and platform stems are wired exactly as for the root specifier.
246
+ const packageName = pluginPackageName(specifier);
239
247
  try {
240
248
  const req = createRequire(path.join(cwd, 'package.json'));
241
- return walkUpToPackageJson(req.resolve(specifier));
249
+ return walkUpToPackageJson(req.resolve(packageName));
242
250
  }
243
251
  catch {
244
- const fallback = path.join(cwd, 'node_modules', specifier, 'package.json');
252
+ const fallback = path.join(cwd, 'node_modules', packageName, 'package.json');
245
253
  return fs.existsSync(fallback) ? fallback : null;
246
254
  }
247
255
  }
256
+ /** Maps a bare plugin specifier to its package name: `@scope/pkg/sub` → `@scope/pkg`, `pkg/sub` → `pkg`. */
257
+ function pluginPackageName(specifier) {
258
+ const segments = specifier.split('/');
259
+ return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
260
+ }
248
261
  /**
249
262
  * Resolves each configured plugin to its package name + available platform stems.
250
263
  * Unresolvable plugins are skipped (the runtime loader reports them separately).
@@ -416,7 +429,7 @@ export class TypeGenerator {
416
429
  const poDirs = this.getPageObjectsDirsForScope(scopeDir, sharedDir);
417
430
  const actionsDirs = this.getEntityDirsForScope('actions', scopeDir, sharedDir);
418
431
  const stepsDirs = this.getEntityDirsForScope('steps', scopeDir, sharedDir);
419
- const isComponentTesting = isComponentTestingProject(cwd);
432
+ const compInfo = detectComponentTesting(cwd);
420
433
  const platforms = ['web', 'android', 'ios', 'mobile', 'common'];
421
434
  const results = {
422
435
  web: '',
@@ -449,83 +462,17 @@ export class TypeGenerator {
449
462
  // forbids authoring it, so leaving it undeclared makes that a compile error too, not just a
450
463
  // lint error.
451
464
  content += `declare function it(title: string, fn: () => Promise<void> | void): void;\n`;
452
- if (isComponentTesting) {
453
- // Vitest's own `expect` global (test.globals: true in tools/react/vitest.config.ts) — the
454
- // one thing component specs legitimately need beyond the Spectra.* DSL, for asserting
455
- // plain closure/callback state (e.g. "was this onClick handler invoked") that has no DOM
456
- // element to target. Reuses @testspectra/react's own re-exported `ExpectStatic` type
457
- // rather than hand-duplicating Vitest's expect API surface. Not declared for E2E projects
458
- // — the Rust orchestrator's BDD runtime has no `expect` global at all.
465
+ if (compInfo.isWebComponent && (platform === 'web' || platform === 'common')) {
459
466
  content += `declare const expect: import('@testspectra/react').ExpectStatic;\n`;
460
467
  }
461
- content += `\n`;
462
- // 1. Page Objects Global Declarations
463
- const declaredPOMs = new Set();
464
- for (const poDir of poDirs) {
465
- if (!fs.existsSync(poDir))
466
- continue;
467
- const poEntries = fs.readdirSync(poDir, { withFileTypes: true });
468
- for (const entry of poEntries) {
469
- if (entry.name.startsWith('.'))
470
- continue;
471
- if (entry.isDirectory()) {
472
- const className = entry.name;
473
- if (declaredPOMs.has(className))
474
- continue;
475
- const classFolder = path.join(poDir, className);
476
- let matchedFile = null;
477
- for (const stem of hierarchy) {
478
- const candidates = [
479
- path.join(classFolder, `${stem}.ts`),
480
- path.join(classFolder, `${stem}.page.ts`),
481
- path.join(classFolder, `${stem}.po.ts`),
482
- ];
483
- if (stem === 'common') {
484
- candidates.push(path.join(classFolder, 'common.ts'));
485
- candidates.push(path.join(classFolder, 'index.ts'));
486
- }
487
- for (const cand of candidates) {
488
- if (fs.existsSync(cand)) {
489
- matchedFile = path.relative(outputTypesDir, cand).replace(/\\/g, '/').replace(/\.ts$/, '.js');
490
- if (!matchedFile.startsWith('.'))
491
- matchedFile = `./${matchedFile}`;
492
- break;
493
- }
494
- }
495
- if (matchedFile)
496
- break;
497
- }
498
- if (matchedFile) {
499
- content += `declare const ${className}: (typeof import('${matchedFile}') extends { default: infer T } ? T : typeof import('${matchedFile}'));\n`;
500
- declaredPOMs.add(className);
501
- }
502
- }
503
- else if (entry.isFile() && entry.name.endsWith('.ts')) {
504
- const parts = entry.name.split('.');
505
- const className = parts[0];
506
- if (declaredPOMs.has(className))
507
- continue;
508
- let isMatch = false;
509
- if (parts.length === 2) {
510
- isMatch = true;
511
- }
512
- else if (parts.length > 2) {
513
- const stem = parts[1];
514
- isMatch = hierarchy.includes(stem);
515
- }
516
- if (isMatch) {
517
- let rel = path
518
- .relative(outputTypesDir, path.join(poDir, entry.name))
519
- .replace(/\\/g, '/')
520
- .replace(/\.ts$/, '.js');
521
- if (!rel.startsWith('.'))
522
- rel = `./${rel}`;
523
- content += `declare const ${className}: (typeof import('${rel}') extends { default: infer T } ? T : typeof import('${rel}'));\n`;
524
- declaredPOMs.add(className);
525
- }
526
- }
527
- }
468
+ else if (compInfo.isMobileComponent &&
469
+ (platform === 'mobile' || platform === 'android' || platform === 'ios' || platform === 'common')) {
470
+ content += `declare const expect: import('@testspectra/react-native').ExpectStatic;\n`;
528
471
  }
472
+ content += `\n`;
473
+ // 1. Page Objects: flat ones as globals, every one also under `Page.<namespace>.<name>`
474
+ const poEntries = poDirs.flatMap((poDir) => collectPageObjectEntries(poDir, hierarchy));
475
+ content += renderPageObjectDeclarations(poEntries, outputTypesDir, cwd);
529
476
  // 2. Built-in Spectra Static Methods & Custom Actions
530
477
  content += `interface TestSpectraCustomActions {\n`;
531
478
  const declaredActions = new Set();
@@ -595,9 +542,24 @@ export class TypeGenerator {
595
542
  }
596
543
  }
597
544
  content += `}\n`;
598
- // Reuses @testspectra/react's own exported `mount` type rather than hand-duplicating its
599
- // signature here — stays in sync automatically if that signature ever changes.
600
- const mountAugment = isComponentTesting ? ` & { mount: typeof import('@testspectra/react').mount }` : '';
545
+ // Reuses @testspectra/react or @testspectra/react-native's own exported `mount` type rather than
546
+ // hand-duplicating its signature here — stays in sync automatically if that signature ever changes.
547
+ let mountPkg = null;
548
+ if (compInfo.isMobileComponent && (platform === 'android' || platform === 'ios' || platform === 'mobile')) {
549
+ mountPkg = '@testspectra/react-native';
550
+ }
551
+ else if (compInfo.isWebComponent && platform === 'web') {
552
+ mountPkg = '@testspectra/react';
553
+ }
554
+ else if (compInfo.isMobileComponent) {
555
+ mountPkg = '@testspectra/react-native';
556
+ }
557
+ else if (compInfo.isWebComponent) {
558
+ mountPkg = '@testspectra/react';
559
+ }
560
+ const mountAugment = mountPkg
561
+ ? ` & { mount: typeof import('${mountPkg}').mount; expect: import('${mountPkg}').ExpectStatic }`
562
+ : '';
601
563
  content += `type TestSpectraActions = import('@testspectra/matchers').SpectraStatic & TestSpectraCustomActions${mountAugment};\n`;
602
564
  content += `declare const Spectra: TestSpectraActions;\n\n`;
603
565
  // 3. Built-in Steps Declaration
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ export function createCliProgram() {
32
32
  .description('Initialize spectra.config.ts and project directories')
33
33
  .option('-t, --template <template>', 'Project template: default, nx, react-component')
34
34
  .option('-f, --force', 'Overwrite existing configuration if present')
35
+ .option('--no-demo', 'Skip the demo plugin, demo specs/page objects/fixtures and WebdriverIO (template default only)')
35
36
  .action(initCommand);
36
37
  program
37
38
  .command('add [module]')
@@ -199,8 +200,9 @@ Examples:
199
200
  .action(runCommand);
200
201
  program
201
202
  .command('test')
202
- .description('Run React component tests via the native CDP engine')
203
+ .description('Run React / React Native component tests via the native CDP engine or mobile harness')
203
204
  .option('-w, --cwd <dir>', 'Custom workspace directory')
205
+ .option('-t, --platform <platform>', 'Target platform: web (default), android, or mobile')
204
206
  .option('-s, --spec <path>', 'Spec file path (or substring filter) to execute')
205
207
  .option('--failed [targets]', 'Re-run failed tests from previous run (accepts comma-separated test IDs or spec paths)')
206
208
  .option('--headless', 'Run in headless mode (default)')
@@ -25,6 +25,29 @@ function resolvesToAmbientGlobalDir(specifier, testFilePath) {
25
25
  segments.includes('fixtures') ||
26
26
  (segments.includes('support') && segments.includes('steps')));
27
27
  }
28
+ /**
29
+ * Classifies a directory entry as a file or a directory. A symbolic link is classified by its target
30
+ * (`fs.statSync` follows the link), so a linked `spec.md` or linked folder is linted like a real one.
31
+ * A dangling link, or one pointing at something that is neither, yields `null`.
32
+ */
33
+ function entryKind(dir, entry) {
34
+ if (entry.isSymbolicLink()) {
35
+ try {
36
+ const target = fs.statSync(path.join(dir, entry.name));
37
+ if (target.isDirectory())
38
+ return 'directory';
39
+ if (target.isFile())
40
+ return 'file';
41
+ }
42
+ catch { }
43
+ return null;
44
+ }
45
+ if (entry.isDirectory())
46
+ return 'directory';
47
+ if (entry.isFile())
48
+ return 'file';
49
+ return null;
50
+ }
28
51
  export class SpecLinter {
29
52
  /**
30
53
  * Validates a single spec.md file.
@@ -122,7 +145,7 @@ export class SpecLinter {
122
145
  try {
123
146
  const entries = fs.readdirSync(specDir, { withFileTypes: true });
124
147
  companionTestFiles = entries
125
- .filter((e) => e.isFile() &&
148
+ .filter((e) => entryKind(specDir, e) === 'file' &&
126
149
  (e.name.endsWith('.test.ts') ||
127
150
  e.name.endsWith('.test.tsx') ||
128
151
  e.name.endsWith('.web.ts') ||
@@ -418,12 +441,19 @@ export class SpecLinter {
418
441
  suiteFiles.push(rootScan);
419
442
  }
420
443
  else {
421
- // Directory traversal
444
+ // Directory traversal. Symlinks are followed (classified by their target); `visited` holds the
445
+ // real path of every folder already walked, so a link cycle cannot recurse forever.
446
+ const visited = new Set();
422
447
  const traverse = (dir) => {
448
+ const realDir = fs.realpathSync(dir);
449
+ if (visited.has(realDir))
450
+ return;
451
+ visited.add(realDir);
423
452
  const entries = fs.readdirSync(dir, { withFileTypes: true });
424
453
  for (const entry of entries) {
425
454
  const fullPath = path.join(dir, entry.name);
426
- if (entry.isDirectory()) {
455
+ const kind = entryKind(dir, entry);
456
+ if (kind === 'directory') {
427
457
  if (entry.name === 'node_modules' ||
428
458
  entry.name === 'dist' ||
429
459
  entry.name === '.git' ||
@@ -432,7 +462,7 @@ export class SpecLinter {
432
462
  }
433
463
  traverse(fullPath);
434
464
  }
435
- else if (entry.isFile()) {
465
+ else if (kind === 'file') {
436
466
  if (entry.name === 'spec.md') {
437
467
  specFiles.push(fullPath);
438
468
  }
@@ -20,6 +20,10 @@ export const ENTITY_NAMING_RULES = {
20
20
  },
21
21
  };
22
22
  export const SUPPORTED_FIXTURE_EXTENSIONS = ['.json', '.csv', '.yaml', '.yml', '.ts', '.js'];
23
+ /** Display symbol of a page object: the bare name when flat, `Page.<Namespace>.<name>` when nested. */
24
+ function pageSymbol(name) {
25
+ return name.includes('/') ? `Page.${name.split('/').join('.')}` : name;
26
+ }
23
27
  export class RefactorEngine {
24
28
  /**
25
29
  * Deterministically parses testing entity type and symbol name from a filesystem path.
@@ -30,11 +34,14 @@ export class RefactorEngine {
30
34
  return null;
31
35
  const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '');
32
36
  const parts = normalized.split('/').filter(Boolean);
33
- // 1. Page Objects: page-objects/<name> or page-objects/<name>/web.ts or page-objects/<name>.ts
37
+ // 1. Page Objects: page-objects/<name>, page-objects/<name>/web.ts, page-objects/<name>.ts, or a nested
38
+ // page-objects/<Namespace>/<name>[/web.ts] whose symbol is the path "<Namespace>/<name>".
34
39
  const poIdx = parts.findIndex((p) => p === 'page-objects');
35
40
  if (poIdx !== -1 && poIdx + 1 < parts.length) {
36
- const rawSymbol = parts[poIdx + 1];
37
- const symbol = path.parse(rawSymbol).name;
41
+ const segments = parts.slice(poIdx + 1);
42
+ const lastSegment = segments[segments.length - 1];
43
+ const folders = segments.length > 1 && path.extname(lastSegment) ? segments.slice(0, -1) : segments;
44
+ const symbol = folders.length === 1 ? path.parse(folders[0]).name : folders.join('/');
38
45
  if (symbol)
39
46
  return { type: 'page-object', symbol };
40
47
  }
@@ -178,7 +185,9 @@ export class RefactorEngine {
178
185
  }
179
186
  }
180
187
  const rule = ENTITY_NAMING_RULES[type];
181
- if (rule && !rule.regex.test(targetNewName)) {
188
+ // A page object may be nested ("GlobalSettings/Brand/List"): every folder segment follows the rule.
189
+ const nameSegments = type === 'page-object' ? targetNewName.split('/') : [targetNewName];
190
+ if (rule && !nameSegments.every((segment) => rule.regex.test(segment))) {
182
191
  return {
183
192
  status: 'failed',
184
193
  entityType: type,
@@ -189,6 +198,12 @@ export class RefactorEngine {
189
198
  error: `Invalid new symbol name "${targetNewName}". Symbol name for ${type} must follow ${rule.convention}.`,
190
199
  };
191
200
  }
201
+ const oldSegments = targetOldName.split('/');
202
+ const newSegments = targetNewName.split('/');
203
+ const oldLeaf = oldSegments[oldSegments.length - 1];
204
+ const newLeaf = newSegments[newSegments.length - 1];
205
+ const isNestedPageObject = type === 'page-object' && oldSegments.length > 1;
206
+ const poSourceDir = path.join(workspaceRoot, 'page-objects', ...oldSegments);
192
207
  // Phase 1: Determine file operations and replacement patterns
193
208
  const plannedRenames = [];
194
209
  const plannedEdits = [];
@@ -222,10 +237,10 @@ export class RefactorEngine {
222
237
  }
223
238
  // 1. Target Directory / File Discovery for Source Entity
224
239
  if (type === 'page-object') {
225
- oldSymbol = targetOldName;
226
- newSymbol = targetNewName;
227
- const targetPoDir = path.join(workspaceRoot, 'page-objects', targetNewName);
228
- const poDir = path.join(workspaceRoot, 'page-objects', targetOldName);
240
+ oldSymbol = pageSymbol(targetOldName);
241
+ newSymbol = pageSymbol(targetNewName);
242
+ const targetPoDir = path.join(workspaceRoot, 'page-objects', ...targetNewName.split('/'));
243
+ const poDir = path.join(workspaceRoot, 'page-objects', ...targetOldName.split('/'));
229
244
  if (fs.existsSync(poDir)) {
230
245
  plannedRenames.push({ from: poDir, to: targetPoDir });
231
246
  }
@@ -318,33 +333,46 @@ export class RefactorEngine {
318
333
  let modified = originalContent;
319
334
  let count = 0;
320
335
  if (type === 'page-object') {
321
- // Replace `class OldName` -> `class NewName`
322
- const classReg = new RegExp(`\\bclass\\s+${oldName}\\b`, 'g');
323
- const classMatches = (modified.match(classReg) || []).length;
324
- if (classMatches > 0) {
325
- modified = modified.replace(classReg, `class ${newName}`);
326
- count += classMatches;
336
+ // Nested page objects are referenced as `Page.<Namespace>.<name>`; flat ones as `Page.<name>` too.
337
+ const pageRefReg = new RegExp(`\\bPage\\.${oldSegments.join('\\.')}\\b`, 'g');
338
+ const pageRefMatches = (modified.match(pageRefReg) || []).length;
339
+ if (pageRefMatches > 0) {
340
+ modified = modified.replace(pageRefReg, `Page.${newSegments.join('.')}`);
341
+ count += pageRefMatches;
327
342
  }
328
- // Replace `new OldName` -> `new NewName` (e.g. `export default new OldName()`, `new OldName(...)`)
329
- const newReg = new RegExp(`\\bnew\\s+${oldName}\\b`, 'g');
330
- const newMatches = (modified.match(newReg) || []).length;
331
- if (newMatches > 0) {
332
- modified = modified.replace(newReg, `new ${newName}`);
333
- count += newMatches;
334
- }
335
- // Replace `export default OldName` -> `export default NewName`
336
- const exportDefaultReg = new RegExp(`\\bexport\\s+default\\s+${oldName}\\b`, 'g');
337
- const exportDefaultMatches = (modified.match(exportDefaultReg) || []).length;
338
- if (exportDefaultMatches > 0) {
339
- modified = modified.replace(exportDefaultReg, `export default ${newName}`);
340
- count += exportDefaultMatches;
343
+ // A nested page object's class lives in its own folder, so only that folder's files are rewritten for it.
344
+ const declaredHere = !isNestedPageObject || filePath.startsWith(poSourceDir + path.sep);
345
+ if (declaredHere) {
346
+ // Replace `class OldName` -> `class NewName`
347
+ const classReg = new RegExp(`\\bclass\\s+${oldLeaf}\\b`, 'g');
348
+ const classMatches = (modified.match(classReg) || []).length;
349
+ if (classMatches > 0) {
350
+ modified = modified.replace(classReg, `class ${newLeaf}`);
351
+ count += classMatches;
352
+ }
353
+ // Replace `new OldName` -> `new NewName` (e.g. `export default new OldName()`, `new OldName(...)`)
354
+ const newReg = new RegExp(`\\bnew\\s+${oldLeaf}\\b`, 'g');
355
+ const newMatches = (modified.match(newReg) || []).length;
356
+ if (newMatches > 0) {
357
+ modified = modified.replace(newReg, `new ${newLeaf}`);
358
+ count += newMatches;
359
+ }
360
+ // Replace `export default OldName` -> `export default NewName`
361
+ const exportDefaultReg = new RegExp(`\\bexport\\s+default\\s+${oldLeaf}\\b`, 'g');
362
+ const exportDefaultMatches = (modified.match(exportDefaultReg) || []).length;
363
+ if (exportDefaultMatches > 0) {
364
+ modified = modified.replace(exportDefaultReg, `export default ${newLeaf}`);
365
+ count += exportDefaultMatches;
366
+ }
341
367
  }
342
- // Replace `OldName.` -> `NewName.`
343
- const refReg = new RegExp(`\\b${oldName}\\.`, 'g');
344
- const refMatches = (modified.match(refReg) || []).length;
345
- if (refMatches > 0) {
346
- modified = modified.replace(refReg, `${newName}.`);
347
- count += refMatches;
368
+ // Replace `OldName.` -> `NewName.` (flat page objects only: a bare nested leaf name is too generic to match)
369
+ if (!isNestedPageObject) {
370
+ const refReg = new RegExp(`\\b${oldName}\\.`, 'g');
371
+ const refMatches = (modified.match(refReg) || []).length;
372
+ if (refMatches > 0) {
373
+ modified = modified.replace(refReg, `${newName}.`);
374
+ count += refMatches;
375
+ }
348
376
  }
349
377
  }
350
378
  else if (type === 'step') {
@@ -416,7 +444,7 @@ export class RefactorEngine {
416
444
  // Target Collision Guards (Priority 2 - checked only if source entity exists and skipPathRename is false)
417
445
  if (!skipPathRename) {
418
446
  if (type === 'page-object') {
419
- const targetPoDir = path.join(workspaceRoot, 'page-objects', targetNewName);
447
+ const targetPoDir = path.join(workspaceRoot, 'page-objects', ...newSegments);
420
448
  if (fs.existsSync(targetPoDir)) {
421
449
  return {
422
450
  status: 'failed',
@@ -0,0 +1,10 @@
1
+ import type { DevServerHealthCheck } from '../config/schema.js';
2
+ /**
3
+ * Rejects a `healthCheck` entry that cannot be evaluated, before any request is sent.
4
+ */
5
+ export declare function validateHealthCheck(serverName: string, check: DevServerHealthCheck): void;
6
+ /**
7
+ * Requests `url + check.path` and reports the first mismatch against the expected status and body text.
8
+ * Resolves to `null` when the response matches, otherwise to a human-readable failure reason.
9
+ */
10
+ export declare function checkDevServerHealth(url: string, check: DevServerHealthCheck): Promise<string | null>;
@@ -0,0 +1,65 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ const HEALTH_CHECK_TIMEOUT_MS = 5000;
4
+ /**
5
+ * Rejects a `healthCheck` entry that cannot be evaluated, before any request is sent.
6
+ */
7
+ export function validateHealthCheck(serverName, check) {
8
+ if (typeof check.path !== 'string' || check.path.trim() === '') {
9
+ throw new Error(`Dev server "${serverName}" healthCheck.path must be a non-empty string.`);
10
+ }
11
+ if (check.status !== undefined && (!Number.isInteger(check.status) || check.status < 100 || check.status > 599)) {
12
+ throw new Error(`Dev server "${serverName}" healthCheck.status must be an HTTP status code (100-599).`);
13
+ }
14
+ if (check.bodyIncludes !== undefined && typeof check.bodyIncludes !== 'string') {
15
+ throw new Error(`Dev server "${serverName}" healthCheck.bodyIncludes must be a string.`);
16
+ }
17
+ }
18
+ /**
19
+ * Requests `url + check.path` and reports the first mismatch against the expected status and body text.
20
+ * Resolves to `null` when the response matches, otherwise to a human-readable failure reason.
21
+ */
22
+ export async function checkDevServerHealth(url, check) {
23
+ const requestPath = check.path.startsWith('/') ? check.path : `/${check.path}`;
24
+ const label = `GET ${requestPath}`;
25
+ const expectedStatus = check.status ?? 200;
26
+ let target;
27
+ try {
28
+ target = new URL(`${url.replace(/\/+$/, '')}${requestPath}`);
29
+ }
30
+ catch {
31
+ return `${label} could not be sent because "${url}" is not a valid URL`;
32
+ }
33
+ const transport = target.protocol === 'https:' ? https : http;
34
+ return new Promise((resolve) => {
35
+ const req = transport.request(target, { method: 'GET', timeout: HEALTH_CHECK_TIMEOUT_MS, rejectUnauthorized: false }, (res) => {
36
+ const status = res.statusCode ?? 0;
37
+ if (status !== expectedStatus) {
38
+ res.resume();
39
+ resolve(`${label} returned ${status} (expected ${expectedStatus})`);
40
+ return;
41
+ }
42
+ if (check.bodyIncludes === undefined) {
43
+ res.resume();
44
+ resolve(null);
45
+ return;
46
+ }
47
+ const expectedText = check.bodyIncludes;
48
+ let body = '';
49
+ res.setEncoding('utf-8');
50
+ res.on('data', (chunk) => {
51
+ body += chunk;
52
+ });
53
+ res.on('end', () => {
54
+ resolve(body.includes(expectedText) ? null : `${label} response body does not include "${expectedText}"`);
55
+ });
56
+ res.on('error', (err) => resolve(`${label} failed: ${err.message}`));
57
+ });
58
+ req.on('timeout', () => {
59
+ req.destroy();
60
+ resolve(`${label} timed out after ${HEALTH_CHECK_TIMEOUT_MS}ms`);
61
+ });
62
+ req.on('error', (err) => resolve(`${label} failed: ${err.message}`));
63
+ req.end();
64
+ });
65
+ }
@@ -27,6 +27,11 @@ export declare class DevServerManager {
27
27
  * reuses the instance. Otherwise, spawns the process and polls until ready.
28
28
  */
29
29
  ensureServer(config: DevServerConfig, rootCwd?: string): Promise<RunningDevServer>;
30
+ /**
31
+ * Verifies the `healthCheck` request when configured. Throws when the response does not match,
32
+ * which means the URL is answered by something other than this project's dev server.
33
+ */
34
+ private assertHealthy;
30
35
  /**
31
36
  * Starts all matching dev servers in parallel.
32
37
  */
@@ -3,6 +3,7 @@ import http from 'node:http';
3
3
  import https from 'node:https';
4
4
  import path from 'node:path';
5
5
  import spawn from 'cross-spawn';
6
+ import { checkDevServerHealth, validateHealthCheck } from './dev-server-health.js';
6
7
  export class DevServerManager {
7
8
  spawnedProcesses = new Map();
8
9
  cleanupRegistered = false;
@@ -113,10 +114,14 @@ export class DevServerManager {
113
114
  const serverName = config.name || config.url;
114
115
  const timeoutMs = config.timeout ?? 60000;
115
116
  const reuse = config.reuseExistingServer !== false;
117
+ if (config.healthCheck)
118
+ validateHealthCheck(serverName, config.healthCheck);
116
119
  // Check if server is already running
117
120
  if (reuse) {
118
121
  const isRunning = await DevServerManager.probeUrl(config.url, 1000);
119
122
  if (isRunning) {
123
+ // A reused server is not ours: fail the run rather than spawn or report it as ready.
124
+ await this.assertHealthy(config, serverName);
120
125
  console.log(`\x1b[36m[TestSpectra DevServer]\x1b[0m ⚡ Reusing active server \x1b[1m"${serverName}"\x1b[0m at \x1b[33m${config.url}\x1b[0m`);
121
126
  return {
122
127
  name: serverName,
@@ -163,6 +168,14 @@ export class DevServerManager {
163
168
  }
164
169
  isReady = await DevServerManager.probeUrl(config.url, 500);
165
170
  if (isReady) {
171
+ try {
172
+ await this.assertHealthy(config, serverName);
173
+ }
174
+ catch (error) {
175
+ this.killProcess(child);
176
+ this.spawnedProcesses.delete(serverName);
177
+ throw error;
178
+ }
166
179
  const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
167
180
  console.log(`\x1b[32m[TestSpectra DevServer] ✓ Server "${serverName}" ready at ${config.url} (${durationSec}s)\x1b[0m`);
168
181
  return {
@@ -181,6 +194,18 @@ export class DevServerManager {
181
194
  throw new Error(`Dev server "${serverName}" timed out after ${timeoutMs}ms waiting for ${config.url}.\n` +
182
195
  `Last output:\n${errorLogs || '(no output)'}`);
183
196
  }
197
+ /**
198
+ * Verifies the `healthCheck` request when configured. Throws when the response does not match,
199
+ * which means the URL is answered by something other than this project's dev server.
200
+ */
201
+ async assertHealthy(config, serverName) {
202
+ if (!config.healthCheck)
203
+ return;
204
+ const failure = await checkDevServerHealth(config.url, config.healthCheck);
205
+ if (failure) {
206
+ throw new Error(`Dev server "${serverName}" at ${config.url} failed its health check: ${failure} — another server may be using the port`);
207
+ }
208
+ }
184
209
  /**
185
210
  * Starts all matching dev servers in parallel.
186
211
  */
@@ -1,2 +1,19 @@
1
1
  export declare function checkUpdateInBackground(): void;
2
+ /**
3
+ * Reads the version of `pkgName` as actually resolved by the running CLI. The CLI's own package is
4
+ * found by walking up from this module (never from the workspace root or cwd); other packages are
5
+ * resolved from this module's location. Returns null when the package cannot be read.
6
+ */
7
+ export declare function readInstalledVersion(pkgName: string): string | null;
8
+ interface CachedUpdate {
9
+ pkg: string;
10
+ latest?: string;
11
+ }
12
+ /** First cached package whose installed version differs from its latest version. */
13
+ export declare function findAvailableUpdate(candidates: CachedUpdate[], resolveInstalled?: (pkgName: string) => string | null): {
14
+ pkg: string;
15
+ current: string;
16
+ latest: string;
17
+ } | null;
2
18
  export declare function displayUpdateNotificationIfNeeded(): void;
19
+ export {};