@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
@@ -1,5 +1,7 @@
1
1
  export interface InitCommandOptions {
2
2
  force?: boolean;
3
- template?: 'default' | 'nx' | 'react-component';
3
+ template?: 'default' | 'nx' | 'react-component' | 'react-native-component';
4
+ /** `false` (from `--no-demo`) scaffolds the default template without demo plugin, demo content or WebdriverIO. */
5
+ demo?: boolean;
4
6
  }
5
7
  export declare function initCommand(options?: InitCommandOptions): Promise<void>;
@@ -7,6 +7,39 @@ import { ConfigLoader } from '../config/loader.js';
7
7
  import { TypeGenerator } from '../types/generator.js';
8
8
  import { TsConfigGenerator } from '../generator/tsconfig-generator.js';
9
9
  import { generateArchitectureDoc } from '../generator/architecture-doc-generator.js';
10
+ /** Top-level template entries that only exist to demonstrate the framework against the demo app. */
11
+ const DEMO_TEMPLATE_ENTRIES = ['specs', 'page-objects', 'fixtures', 'support'];
12
+ /** Empty directories a `--no-demo` scaffold keeps, each with a `.gitkeep`. */
13
+ const DEMO_FREE_DIRECTORIES = ['specs', 'page-objects', 'fixtures', 'support/steps', 'support/actions'];
14
+ const DEMO_CONFIG_REPLACEMENTS = {
15
+ ' environmentVariables: { DEMO_API_MODE: "sandbox" },': ' environmentVariables: {},',
16
+ ' // Demo plugin: registers Spectra.findComponent() / Spectra.findButton() commands.\n plugins: ["@testspectra/plugin-demo"],\n': '',
17
+ };
18
+ function isDemoOnlyDependency(name) {
19
+ return name === '@testspectra/plugin-demo' || name === 'webdriverio' || name.startsWith('@wdio/');
20
+ }
21
+ function scaffoldEmptyDirectories(root) {
22
+ for (const rel of DEMO_FREE_DIRECTORIES) {
23
+ const dir = path.join(root, rel);
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ const keep = path.join(dir, '.gitkeep');
26
+ if (!fs.existsSync(keep))
27
+ fs.writeFileSync(keep, '', 'utf-8');
28
+ }
29
+ }
30
+ /** Nearest ancestor of `cwd` (excluding `cwd`) that holds a pnpm-workspace.yaml, if any. */
31
+ function findAncestorPnpmWorkspace(cwd) {
32
+ let dir = path.dirname(cwd);
33
+ for (;;) {
34
+ const candidate = path.join(dir, 'pnpm-workspace.yaml');
35
+ if (fs.existsSync(candidate))
36
+ return candidate;
37
+ const parent = path.dirname(dir);
38
+ if (parent === dir)
39
+ return null;
40
+ dir = parent;
41
+ }
42
+ }
10
43
  function parsePnpmWorkspaceGlobs(content) {
11
44
  const lines = content.split('\n');
12
45
  const globs = [];
@@ -225,6 +258,11 @@ export async function initCommand(options = {}) {
225
258
  value: 'react-component',
226
259
  hint: 'Real-browser React component testing with Vite & Testing Library',
227
260
  },
261
+ {
262
+ label: 'React Native Component Testing',
263
+ value: 'react-native-component',
264
+ hint: 'Real-device/emulator React Native component testing with Hermes',
265
+ },
228
266
  ],
229
267
  });
230
268
  if (p.isCancel(templateChoice)) {
@@ -234,6 +272,9 @@ export async function initCommand(options = {}) {
234
272
  selectedTemplate = templateChoice;
235
273
  }
236
274
  const templateName = selectedTemplate || (isMonorepo ? 'nx' : 'default');
275
+ if (options.demo === false && templateName !== 'default') {
276
+ p.log.warn(chalk.yellow('--no-demo only applies to the default template; ignoring it for this template.'));
277
+ }
237
278
  // 1. Resolve template directory
238
279
  const __filename = fileURLToPath(import.meta.url);
239
280
  const __dirname = path.dirname(__filename);
@@ -267,8 +308,9 @@ export async function initCommand(options = {}) {
267
308
  catch { }
268
309
  const projectName = path.basename(cwd);
269
310
  const cliDepVersion = cliVersion.startsWith('^') ? cliVersion : `^${cliVersion}`;
270
- // Helper: Copy directory contents recursively
271
- function copyRecursive(src, dest, replacements = {}) {
311
+ // Helper: Copy directory contents recursively. `demoFree` drops the demo-only template entries and
312
+ // their dependencies/config (see --no-demo).
313
+ function copyRecursive(src, dest, replacements = {}, demoFree = false) {
272
314
  const base = path.basename(src);
273
315
  if (base === 'node_modules' || base === 'dist' || base === '.testspectra' || base === '.git') {
274
316
  return;
@@ -280,7 +322,9 @@ export async function initCommand(options = {}) {
280
322
  }
281
323
  const entries = fs.readdirSync(src);
282
324
  for (const entry of entries) {
283
- copyRecursive(path.join(src, entry), path.join(dest, entry), replacements);
325
+ if (demoFree && src === templateDir && DEMO_TEMPLATE_ENTRIES.includes(entry))
326
+ continue;
327
+ copyRecursive(path.join(src, entry), path.join(dest, entry), replacements, demoFree);
284
328
  }
285
329
  }
286
330
  else {
@@ -291,12 +335,23 @@ export async function initCommand(options = {}) {
291
335
  for (const [k, v] of Object.entries(replacements)) {
292
336
  content = content.replaceAll(k, v);
293
337
  }
338
+ if (demoFree && dest === path.join(cwd, 'spectra.config.ts')) {
339
+ for (const [k, v] of Object.entries(DEMO_CONFIG_REPLACEMENTS)) {
340
+ content = content.replaceAll(k, v);
341
+ }
342
+ }
294
343
  if (dest.endsWith('package.json')) {
295
344
  try {
296
345
  const pkg = JSON.parse(content);
297
346
  if (!pkg.name || pkg.name.startsWith('@testspectra/template-') || pkg.name === 'spectra-monorepo') {
298
347
  pkg.name = projectName;
299
348
  }
349
+ if (demoFree && pkg.devDependencies) {
350
+ for (const key of Object.keys(pkg.devDependencies)) {
351
+ if (isDemoOnlyDependency(key))
352
+ delete pkg.devDependencies[key];
353
+ }
354
+ }
300
355
  if (pkg.devDependencies) {
301
356
  for (const key of Object.keys(pkg.devDependencies)) {
302
357
  if (key.startsWith('@testspectra/') ||
@@ -606,9 +661,16 @@ export async function initCommand(options = {}) {
606
661
  // --- STANDALONE / DEFAULT PROJECT FLOW ---
607
662
  const s = p.spinner();
608
663
  s.start('Scaffolding standalone TestSpectra project...');
609
- copyRecursive(templateDir, cwd);
664
+ const demoFree = options.demo === false && templateName === 'default';
665
+ copyRecursive(templateDir, cwd, {}, demoFree);
666
+ if (demoFree)
667
+ scaffoldEmptyDirectories(cwd);
610
668
  const standaloneWsPath = path.join(cwd, 'pnpm-workspace.yaml');
611
- if (!fs.existsSync(standaloneWsPath) || options.force) {
669
+ const ancestorWsPath = findAncestorPnpmWorkspace(cwd);
670
+ if (ancestorWsPath) {
671
+ p.log.info(chalk.gray(`Using the pnpm workspace at ${path.relative(cwd, ancestorWsPath) || ancestorWsPath}; no nested pnpm-workspace.yaml written.`));
672
+ }
673
+ else if (!fs.existsSync(standaloneWsPath) || options.force) {
612
674
  const pnpmWsContent = `allowBuilds:\n chromedriver: true\n edgedriver: true\n geckodriver: true\n`;
613
675
  fs.writeFileSync(standaloneWsPath, pnpmWsContent, 'utf-8');
614
676
  }
@@ -8,6 +8,10 @@ export interface TestCommandOptions {
8
8
  failed?: boolean | string;
9
9
  output?: string;
10
10
  concurrency?: string | number;
11
+ platform?: string;
12
+ target?: string;
13
+ port?: string | number;
14
+ deviceId?: string;
11
15
  }
12
16
  /**
13
17
  * Guarantees the `.testspectra` solution-tsconfig scaffolding exists before the component
@@ -7,6 +7,7 @@ import { resolvePluginSpecifiers } from '../plugins/resolver.js';
7
7
  import { renderSummaryView } from '../reporter/summary-view.js';
8
8
  import { Reporter } from '../runner/reporter.js';
9
9
  import { RustCoreBridge } from '../runner/bridge.js';
10
+ import { HarnessGenerator } from '../generator/harness-generator.js';
10
11
  import { TypeGenerator } from '../generator/type-generator.js';
11
12
  import { runSystemChecks } from './doctor.js';
12
13
  /**
@@ -70,10 +71,112 @@ export function collectFailedTestTitles(reportPath) {
70
71
  * runners rather than one extended pipeline.
71
72
  */
72
73
  export async function testCommand(options = {}) {
73
- const cwd = options.cwd ?? process.cwd();
74
+ const cwd = path.resolve(process.cwd(), options.cwd ?? '.');
74
75
  // User-facing phase log. Deliberately describes TestSpectra's own stages only — never the
75
76
  // bundled runtime/toolchain underneath — so the CLI surface stays implementation-agnostic.
76
77
  const logPhase = (message) => p.log.step(chalk.dim(message));
78
+ const rawPlatform = options.platform ?? options.target ?? 'web';
79
+ const platform = String(rawPlatform).toLowerCase();
80
+ const isMobile = platform === 'android' || platform === 'mobile';
81
+ if (isMobile) {
82
+ logPhase('Preparing mobile component test workspace…');
83
+ ensureTestSpectraScaffolding(cwd);
84
+ HarnessGenerator.ensureMobileHarness(cwd);
85
+ let rnPkg;
86
+ try {
87
+ rnPkg = await import('@testspectra/react-native');
88
+ }
89
+ catch {
90
+ p.log.error(chalk.red('✖ @testspectra/react-native is not installed in this workspace.'));
91
+ p.log.info(chalk.dim('Mobile component testing requires the @testspectra/react-native package.'));
92
+ p.log.info(chalk.dim(' 👉 Install it: pnpm add -D @testspectra/react-native'));
93
+ process.exit(1);
94
+ return;
95
+ }
96
+ const isLibraryProject = !fs.existsSync(path.join(cwd, 'android')) && !fs.existsSync(path.join(cwd, 'ios'));
97
+ const reporter = new Reporter();
98
+ const runStartedAt = Date.now();
99
+ const outputFile = options.output ? path.resolve(cwd, options.output) : undefined;
100
+ let ok = false;
101
+ let testResult;
102
+ if (isLibraryProject) {
103
+ logPhase('Executing mobile component library tests…');
104
+ testResult = await rnPkg.runMobileComponentTests({
105
+ testRoot: cwd,
106
+ spec: options.spec,
107
+ reporter,
108
+ mode: 'library',
109
+ });
110
+ ok = testResult.status === 'passed';
111
+ if (!ok) {
112
+ for (const res of testResult.results) {
113
+ if (!res.passed && res.error) {
114
+ p.log.error(chalk.red(`✖ [${res.specPath}] ${res.error}`));
115
+ }
116
+ }
117
+ }
118
+ }
119
+ else {
120
+ logPhase('Checking ADB device connection…');
121
+ const adbCheck = await rnPkg.checkAdbDevices();
122
+ if (!adbCheck.connected || adbCheck.devices.length === 0) {
123
+ p.log.error(chalk.red('✖ No connected Android devices or emulators found.'));
124
+ p.log.info(chalk.dim('Ensure an Android emulator is running or a device is connected with USB debugging.'));
125
+ p.log.info(chalk.dim(' 👉 Run: adb devices'));
126
+ process.exit(1);
127
+ return;
128
+ }
129
+ p.log.info(chalk.dim(`Connected Android device(s): ${adbCheck.devices.join(', ')}`));
130
+ logPhase('Starting mobile bundle server (esbuild React Native)…');
131
+ const bundleServer = await rnPkg.startMobileBundleServer({
132
+ testRoot: cwd,
133
+ port: options.port !== undefined ? parseInt(String(options.port), 10) : undefined,
134
+ });
135
+ try {
136
+ logPhase('Executing mobile component tests on device…');
137
+ testResult = await rnPkg.runMobileComponentTests({
138
+ testRoot: cwd,
139
+ spec: options.spec,
140
+ reporter,
141
+ bundleUrl: bundleServer.url,
142
+ deviceId: options.deviceId,
143
+ });
144
+ ok = testResult.status === 'passed';
145
+ if (!ok) {
146
+ for (const res of testResult.results) {
147
+ if (!res.passed && res.error) {
148
+ p.log.error(chalk.red(`✖ [${res.specPath}] ${res.error}`));
149
+ }
150
+ }
151
+ }
152
+ }
153
+ finally {
154
+ await bundleServer.stop();
155
+ }
156
+ }
157
+ const durationMs = Date.now() - runStartedAt;
158
+ const passedCount = testResult?.passedCount ?? 0;
159
+ const failedCount = ok ? 0 : Math.max(testResult?.failedCount ?? 0, 1);
160
+ const totalSuites = testResult?.suitesRun ?? passedCount + failedCount;
161
+ const totalTests = passedCount + failedCount;
162
+ const result = reporter.generateResult(ok ? 'passed' : 'failed', `${(durationMs / 1000).toFixed(1)}s`);
163
+ if (outputFile) {
164
+ try {
165
+ fs.mkdirSync(path.dirname(outputFile), { recursive: true });
166
+ fs.writeFileSync(outputFile, JSON.stringify(result, null, 2), 'utf8');
167
+ }
168
+ catch { }
169
+ }
170
+ renderSummaryView({
171
+ totalSuites,
172
+ totalTests,
173
+ passedCount,
174
+ failedCount,
175
+ durationMs,
176
+ reportLocation: outputFile ? path.relative(cwd, outputFile) : '(use -o/--output to save a report)',
177
+ });
178
+ process.exit(ok ? 0 : 1);
179
+ }
77
180
  logPhase('Preparing component test workspace…');
78
181
  ensureTestSpectraScaffolding(cwd);
79
182
  let reactPkg;
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Request a dev server must answer correctly before the run proceeds. Guards against an unrelated
3
+ * server that happens to occupy the port (e.g. one reused via `reuseExistingServer`).
4
+ */
5
+ export interface DevServerHealthCheck {
6
+ /**
7
+ * Path requested on the dev server `url` (e.g. "/__mock/scenarios").
8
+ */
9
+ path: string;
10
+ /**
11
+ * Expected HTTP status code of the response.
12
+ * @default 200
13
+ */
14
+ status?: number;
15
+ /**
16
+ * Text the response body must contain. When omitted, the body is not inspected.
17
+ */
18
+ bodyIncludes?: string;
19
+ }
1
20
  /**
2
21
  * Configuration for an individual local development server lifecycle.
3
22
  */
@@ -36,6 +55,11 @@ export interface DevServerConfig {
36
55
  * Custom environment variables passed to the spawned development server process.
37
56
  */
38
57
  env?: Record<string, string>;
58
+ /**
59
+ * Request verified after the server is ready (reused or started). The run fails if the response
60
+ * does not match. Omit to skip the check.
61
+ */
62
+ healthCheck?: DevServerHealthCheck;
39
63
  }
40
64
  /**
41
65
  * Configuration options for failure-only video recording.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * HarnessGenerator — manages auto-generated component testing harness apps.
3
+ *
4
+ * Automatically provisions the standalone mobile component harness container
5
+ * (<SpectraHarnessRoot />) inside `.testspectra/harness/` for both React Native
6
+ * component libraries and full mobile application workspaces.
7
+ */
8
+ export declare class HarnessGenerator {
9
+ /**
10
+ * Ensures the mobile component harness entry files exist in `.testspectra/harness/`.
11
+ */
12
+ static ensureMobileHarness(cwd: string): void;
13
+ }
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * HarnessGenerator — manages auto-generated component testing harness apps.
5
+ *
6
+ * Automatically provisions the standalone mobile component harness container
7
+ * (<SpectraHarnessRoot />) inside `.testspectra/harness/` for both React Native
8
+ * component libraries and full mobile application workspaces.
9
+ */
10
+ export class HarnessGenerator {
11
+ /**
12
+ * Ensures the mobile component harness entry files exist in `.testspectra/harness/`.
13
+ */
14
+ static ensureMobileHarness(cwd) {
15
+ const harnessDir = path.join(cwd, '.testspectra', 'harness');
16
+ if (!fs.existsSync(harnessDir)) {
17
+ fs.mkdirSync(harnessDir, { recursive: true });
18
+ }
19
+ const appFile = path.join(harnessDir, 'App.tsx');
20
+ if (!fs.existsSync(appFile)) {
21
+ const appContent = `/**
22
+ * Auto-generated by TestSpectra.
23
+ * Standalone Mobile Component Testing Harness Container.
24
+ * DO NOT EDIT MANUALLY.
25
+ */
26
+ import React from 'react';
27
+ import { AppRegistry } from 'react-native';
28
+ import { SpectraHarnessRoot } from '@testspectra/react-native';
29
+
30
+ export function SpectraHarnessApp(): React.JSX.Element {
31
+ return <SpectraHarnessRoot />;
32
+ }
33
+
34
+ AppRegistry.registerComponent('SpectraHarness', () => SpectraHarnessApp);
35
+ export default SpectraHarnessApp;
36
+ `;
37
+ fs.writeFileSync(appFile, appContent, 'utf8');
38
+ }
39
+ const indexFile = path.join(harnessDir, 'index.js');
40
+ if (!fs.existsSync(indexFile)) {
41
+ const indexContent = `/**
42
+ * Auto-generated by TestSpectra.
43
+ * Standalone Mobile Component Testing Harness Entrypoint.
44
+ * DO NOT EDIT MANUALLY.
45
+ */
46
+ import './App.js';
47
+ `;
48
+ fs.writeFileSync(indexFile, indexContent, 'utf8');
49
+ }
50
+ }
51
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './type-generator.js';
2
2
  export * from './tsconfig-generator.js';
3
3
  export * from './architecture-doc-generator.js';
4
+ export * from './harness-generator.js';
@@ -1,3 +1,4 @@
1
1
  export * from './type-generator.js';
2
2
  export * from './tsconfig-generator.js';
3
3
  export * from './architecture-doc-generator.js';
4
+ export * from './harness-generator.js';
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Page object discovery and ambient declarations. Mirrors `core/orchestrator`'s discovery and
3
+ * `core/worker-runtime`'s `publishPageObject`, so declared types and injected values share one tree:
4
+ * a folder holding a platform file is a page object; any other folder is a namespace; every page object
5
+ * is `Page.<namespace>.<name>`, and a flat one (directly under `page-objects/`) is also a global `<name>`.
6
+ */
7
+ export interface PageObjectEntry {
8
+ /** Folders between `page-objects/` and the page object's own folder. Empty for a flat page object. */
9
+ namespace: string[];
10
+ name: string;
11
+ /** Absolute path of the platform file that makes this entry a page object. */
12
+ file: string;
13
+ /** Absolute path of the page object's folder, or of the loose file for a flat `<Name>.<stem>.ts` entry. */
14
+ source: string;
15
+ }
16
+ /** The platform file that makes `folder` a page object: `<stem>.ts`, `<stem>.page.ts`, `<stem>.po.ts`, `<stem>.js`, and `index.ts` for the common stem. */
17
+ export declare function findFolderPageObjectFile(folder: string, hierarchy: string[]): string | null;
18
+ /**
19
+ * Collects the page objects under one `page-objects/` root at any depth. Loose `<Name>.ts` /
20
+ * `<Name>.<stem>.ts` files directly under the root remain flat page objects.
21
+ */
22
+ export declare function collectPageObjectEntries(poDir: string, hierarchy: string[]): PageObjectEntry[];
23
+ /**
24
+ * Renders the page-object declarations for one platform file: a `declare const <Name>` for each flat page
25
+ * object, and one `declare const Page` holding the whole tree. Returns `''` when there are no entries.
26
+ */
27
+ export declare function renderPageObjectDeclarations(entries: PageObjectEntry[], outputTypesDir: string, cwd: string): string;
@@ -0,0 +1,142 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
4
+ /** The platform file that makes `folder` a page object: `<stem>.ts`, `<stem>.page.ts`, `<stem>.po.ts`, `<stem>.js`, and `index.ts` for the common stem. */
5
+ export function findFolderPageObjectFile(folder, hierarchy) {
6
+ for (const stem of hierarchy) {
7
+ const candidates = [`${stem}.ts`, `${stem}.page.ts`, `${stem}.po.ts`, `${stem}.js`];
8
+ if (stem === 'common')
9
+ candidates.push('index.ts');
10
+ for (const name of candidates) {
11
+ const cand = path.join(folder, name);
12
+ if (fs.existsSync(cand) && fs.statSync(cand).isFile())
13
+ return cand;
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+ /**
19
+ * Collects the page objects under one `page-objects/` root at any depth. Loose `<Name>.ts` /
20
+ * `<Name>.<stem>.ts` files directly under the root remain flat page objects.
21
+ */
22
+ export function collectPageObjectEntries(poDir, hierarchy) {
23
+ const entries = [];
24
+ if (!fs.existsSync(poDir))
25
+ return entries;
26
+ const walk = (dir, namespace, isRoot) => {
27
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
28
+ if (entry.name.startsWith('.'))
29
+ continue;
30
+ const fullPath = path.join(dir, entry.name);
31
+ if (entry.isDirectory()) {
32
+ const file = findFolderPageObjectFile(fullPath, hierarchy);
33
+ if (file) {
34
+ entries.push({ namespace, name: entry.name, file, source: fullPath });
35
+ }
36
+ else {
37
+ walk(fullPath, [...namespace, entry.name], false);
38
+ }
39
+ }
40
+ else if (isRoot && entry.isFile() && entry.name.endsWith('.ts')) {
41
+ const parts = entry.name.split('.');
42
+ if (parts.length === 2 || (parts.length > 2 && hierarchy.includes(parts[1]))) {
43
+ entries.push({ namespace: [], name: parts[0], file: fullPath, source: fullPath });
44
+ }
45
+ }
46
+ }
47
+ };
48
+ walk(poDir, [], true);
49
+ return entries;
50
+ }
51
+ /** Import type of a page object file, the same expression the generator has always emitted. */
52
+ function importedType(file, outputTypesDir) {
53
+ let rel = path.relative(outputTypesDir, file).replace(/\\/g, '/').replace(/\.ts$/, '.js');
54
+ if (!rel.startsWith('.'))
55
+ rel = `./${rel}`;
56
+ return `(typeof import('${rel}') extends { default: infer T } ? T : typeof import('${rel}'))`;
57
+ }
58
+ function dottedPath(namespace, name) {
59
+ return ['Page', ...namespace, name].join('.');
60
+ }
61
+ /**
62
+ * The folder of the first `depth` namespace segments of `entry`. The entry's source is its own folder, one level
63
+ * below the deepest namespace, so the depth-`d` folder is `namespace.length - d + 1` levels up.
64
+ */
65
+ function namespaceFolder(entry, depth) {
66
+ let folder = entry.source;
67
+ for (let up = entry.namespace.length - depth + 1; up > 0; up--)
68
+ folder = path.dirname(folder);
69
+ return folder;
70
+ }
71
+ function shown(p, cwd) {
72
+ return path.relative(cwd, p).replace(/\\/g, '/');
73
+ }
74
+ /**
75
+ * Builds the `Page` tree and throws when two entries claim the same path: two page objects at one
76
+ * namespace level, or a namespace and a page object with the same name. The message names both paths.
77
+ */
78
+ function buildPageTree(entries, cwd) {
79
+ const root = { children: new Map() };
80
+ for (const entry of entries) {
81
+ if (entry.namespace.length === 0 && entry.name === 'Page') {
82
+ throw new Error(`Page object name conflict: "${shown(entry.source, cwd)}" is named "Page", the reserved root of every page object`);
83
+ }
84
+ let node = root;
85
+ entry.namespace.forEach((segment, index) => {
86
+ const child = node.children.get(segment);
87
+ if (!child) {
88
+ const created = { children: new Map(), folder: namespaceFolder(entry, index + 1) };
89
+ node.children.set(segment, created);
90
+ node = created;
91
+ return;
92
+ }
93
+ if (child.leaf) {
94
+ throw new Error(`Page object conflict: "${dottedPath(entry.namespace.slice(0, index), segment)}" is both a page object (${shown(child.leaf.source, cwd)}) and a namespace (${shown(namespaceFolder(entry, index + 1), cwd)})`);
95
+ }
96
+ node = child;
97
+ });
98
+ const existing = node.children.get(entry.name);
99
+ if (existing?.leaf) {
100
+ throw new Error(`Duplicate page object "${dottedPath(entry.namespace, entry.name)}": ${shown(existing.leaf.source, cwd)} and ${shown(entry.source, cwd)}`);
101
+ }
102
+ if (existing) {
103
+ throw new Error(`Page object conflict: "${dottedPath(entry.namespace, entry.name)}" is both a page object (${shown(entry.source, cwd)}) and a namespace (${shown(existing.folder ?? '', cwd)})`);
104
+ }
105
+ node.children.set(entry.name, { children: new Map(), leaf: entry });
106
+ }
107
+ return root;
108
+ }
109
+ function propertyName(name) {
110
+ return IDENTIFIER.test(name) ? name : JSON.stringify(name);
111
+ }
112
+ function renderObjectType(node, indent, outputTypesDir) {
113
+ const lines = ['{'];
114
+ for (const [name, child] of node.children) {
115
+ const key = propertyName(name);
116
+ if (child.leaf) {
117
+ lines.push(`${indent} ${key}: ${importedType(child.leaf.file, outputTypesDir)};`);
118
+ }
119
+ else {
120
+ lines.push(`${indent} ${key}: ${renderObjectType(child, `${indent} `, outputTypesDir)};`);
121
+ }
122
+ }
123
+ lines.push(`${indent}}`);
124
+ return lines.join('\n');
125
+ }
126
+ /**
127
+ * Renders the page-object declarations for one platform file: a `declare const <Name>` for each flat page
128
+ * object, and one `declare const Page` holding the whole tree. Returns `''` when there are no entries.
129
+ */
130
+ export function renderPageObjectDeclarations(entries, outputTypesDir, cwd) {
131
+ if (entries.length === 0)
132
+ return '';
133
+ const root = buildPageTree(entries, cwd);
134
+ let content = '';
135
+ for (const [name, child] of root.children) {
136
+ if (child.leaf) {
137
+ content += `declare const ${name}: ${importedType(child.leaf.file, outputTypesDir)};\n`;
138
+ }
139
+ }
140
+ content += `declare const Page: ${renderObjectType(root, '', outputTypesDir)};\n`;
141
+ return content;
142
+ }
@@ -22,10 +22,15 @@ export interface EntityScope {
22
22
  */
23
23
  export declare function discoverEntityScopes(cwd: string): EntityScope[];
24
24
  /**
25
- * Detects a component-testing project (`@testspectra/react`'s Vitest browser-mode runner), via
26
- * the same signal `cli/src/commands/test.ts` uses to gate `spectra test` itself. Used to augment
27
- * the generated `Spectra` ambient type with `.mount()`, which only exists in that runtime.
25
+ * Detects component-testing capabilities (Web via `@testspectra/react` or Mobile via `@testspectra/react-native`).
26
+ * Used to augment the generated `Spectra` ambient type with `.mount()`.
28
27
  */
28
+ export interface ComponentTestingInfo {
29
+ isComponent: boolean;
30
+ isWebComponent: boolean;
31
+ isMobileComponent: boolean;
32
+ }
33
+ export declare function detectComponentTesting(cwd: string): ComponentTestingInfo;
29
34
  export declare function isComponentTestingProject(cwd: string): boolean;
30
35
  export declare class TypeGenerator {
31
36
  /**