@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.
- package/dist/commands/init.d.ts +3 -1
- package/dist/commands/init.js +67 -5
- package/dist/commands/test.d.ts +4 -0
- package/dist/commands/test.js +104 -1
- package/dist/config/schema.d.ts +24 -0
- package/dist/generator/harness-generator.d.ts +13 -0
- package/dist/generator/harness-generator.js +51 -0
- package/dist/generator/index.d.ts +1 -0
- package/dist/generator/index.js +1 -0
- package/dist/generator/page-object-declarations.d.ts +27 -0
- package/dist/generator/page-object-declarations.js +142 -0
- package/dist/generator/type-generator.d.ts +8 -3
- package/dist/generator/type-generator.js +51 -89
- package/dist/index.js +3 -1
- package/dist/linter/spec-linter.js +34 -4
- package/dist/refactor/refactor-engine.js +62 -34
- package/dist/utils/dev-server-health.d.ts +10 -0
- package/dist/utils/dev-server-health.js +65 -0
- package/dist/utils/dev-server-manager.d.ts +5 -0
- package/dist/utils/dev-server-manager.js +25 -0
- package/dist/utils/update-notifier.d.ts +17 -0
- package/dist/utils/update-notifier.js +47 -3
- package/package.json +9 -8
- package/templates/default/package.json +3 -3
- package/templates/nx/package.json +3 -3
- package/templates/react-component/package.json +3 -3
- package/templates/react-native-component/.testspectra/harness/App.tsx +15 -0
- package/templates/react-native-component/.testspectra/harness/index.js +6 -0
- package/templates/react-native-component/.testspectra/tsconfig/android.json +105 -0
- package/templates/react-native-component/.testspectra/tsconfig/android.tsbuildinfo +1 -0
- package/templates/react-native-component/.testspectra/tsconfig/common.json +164 -0
- package/templates/react-native-component/.testspectra/tsconfig/common.tsbuildinfo +1 -0
- package/templates/react-native-component/.testspectra/tsconfig/ios.json +105 -0
- package/templates/react-native-component/.testspectra/tsconfig/ios.tsbuildinfo +1 -0
- package/templates/react-native-component/.testspectra/tsconfig/mobile.json +137 -0
- package/templates/react-native-component/.testspectra/tsconfig/mobile.tsbuildinfo +1 -0
- package/templates/react-native-component/.testspectra/tsconfig/web.json +137 -0
- package/templates/react-native-component/.testspectra/tsconfig/web.tsbuildinfo +1 -0
- package/templates/react-native-component/.testspectra/tsconfig.json +29 -0
- package/templates/react-native-component/.testspectra/types/android.d.ts +22 -0
- package/templates/react-native-component/.testspectra/types/common.d.ts +19 -0
- package/templates/react-native-component/.testspectra/types/env.d.ts +6 -0
- package/templates/react-native-component/.testspectra/types/fixtures.d.ts +6 -0
- package/templates/react-native-component/.testspectra/types/index.d.ts +1 -0
- package/templates/react-native-component/.testspectra/types/ios.d.ts +22 -0
- package/templates/react-native-component/.testspectra/types/mobile.d.ts +22 -0
- package/templates/react-native-component/.testspectra/types/plugins/android.d.ts +2 -0
- package/templates/react-native-component/.testspectra/types/plugins/common.d.ts +2 -0
- package/templates/react-native-component/.testspectra/types/plugins/ios.d.ts +2 -0
- package/templates/react-native-component/.testspectra/types/plugins/mobile.d.ts +2 -0
- package/templates/react-native-component/.testspectra/types/plugins/web.d.ts +2 -0
- package/templates/react-native-component/.testspectra/types/web.d.ts +18 -0
- package/templates/react-native-component/.vscode/extensions.json +5 -0
- package/templates/react-native-component/.vscode/launch.json +32 -0
- package/templates/react-native-component/CLAUDE.md +13 -0
- package/templates/react-native-component/README.md +40 -0
- package/templates/react-native-component/node_modules/.bin/react-native +43 -0
- package/templates/react-native-component/node_modules/.bin/spectra +43 -0
- package/templates/react-native-component/node_modules/.bin/tsc +43 -0
- package/templates/react-native-component/node_modules/.bin/tsserver +43 -0
- package/templates/react-native-component/package.json +25 -0
- package/templates/react-native-component/page-objects/BadgeComponent/mobile.ts +13 -0
- package/templates/react-native-component/page-objects/ButtonComponent/mobile.ts +13 -0
- package/templates/react-native-component/page-objects/InputComponent/mobile.ts +8 -0
- package/templates/react-native-component/specs/BadgeComponent/TC-0002-badge-render/mobile.test.tsx +9 -0
- package/templates/react-native-component/specs/BadgeComponent/TC-0002-badge-render/spec.md +10 -0
- package/templates/react-native-component/specs/ButtonComponent/TC-0001-button-press/mobile.test.tsx +21 -0
- package/templates/react-native-component/specs/ButtonComponent/TC-0001-button-press/spec.md +10 -0
- package/templates/react-native-component/specs/InputComponent/TC-0003-input-typing/mobile.test.tsx +19 -0
- package/templates/react-native-component/specs/InputComponent/TC-0003-input-typing/spec.md +10 -0
- package/templates/react-native-component/spectra.config.ts +10 -0
- package/templates/react-native-component/src/components/Badge/Badge.tsx +56 -0
- package/templates/react-native-component/src/components/Button/Button.tsx +63 -0
- package/templates/react-native-component/src/components/Input/Input.tsx +56 -0
- 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
|
-
|
|
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(
|
|
249
|
+
return walkUpToPackageJson(req.resolve(packageName));
|
|
242
250
|
}
|
|
243
251
|
catch {
|
|
244
|
-
const fallback = path.join(cwd, 'node_modules',
|
|
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
|
|
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 (
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
599
|
-
// signature here — stays in sync automatically if that signature ever changes.
|
|
600
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
37
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
if (
|
|
325
|
-
modified = modified.replace(
|
|
326
|
-
count +=
|
|
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
|
-
//
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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',
|
|
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 {};
|