@testspectra/cli 1.1.14 → 1.2.0

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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { ConfigLoader } from '../config/loader.js';
4
+ import { resolvePluginSpecifiers } from '../plugins/resolver.js';
4
5
  import { RustCoreBridge } from '../runner/bridge.js';
5
6
  import { Reporter } from '../runner/reporter.js';
6
7
  import { TypeGenerator } from '../types/generator.js';
@@ -137,6 +138,7 @@ export async function runCommand(specPaths, options = {}) {
137
138
  // Ensure latest ambient types are up-to-date
138
139
  TypeGenerator.writeDeclarationFiles(cwd);
139
140
  await TypeGenerator.syncEnvDeclaration(cwd);
141
+ await TypeGenerator.syncPluginDeclarations(cwd);
140
142
  const home = process.env.HOME || process.env.USERPROFILE || '';
141
143
  const globalAppDataPath = path.join(home, '.testspectra');
142
144
  const appDataPath = options.workdir ? path.resolve(options.workdir) : globalAppDataPath;
@@ -168,6 +170,9 @@ export async function runCommand(specPaths, options = {}) {
168
170
  }
169
171
  }
170
172
  const config = await ConfigLoader.loadConfig(cwd);
173
+ if (config.executionConfig) {
174
+ config.executionConfig.plugins = resolvePluginSpecifiers(cwd, config.executionConfig.plugins);
175
+ }
171
176
  if (options.baseUrl) {
172
177
  config.webConfig.baseUrl = options.baseUrl;
173
178
  }
@@ -6,6 +6,7 @@ export async function syncTypesCommand(options = {}) {
6
6
  try {
7
7
  TypeGenerator.generateAll(cwd);
8
8
  await TypeGenerator.syncEnvDeclaration(cwd);
9
+ await TypeGenerator.syncPluginDeclarations(cwd);
9
10
  console.log(chalk.green('✔ Successfully synchronized TestSpectra ambient type declarations in .testspectra/types/'));
10
11
  }
11
12
  catch (err) {
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import * as p from '@clack/prompts';
5
5
  import { ConfigLoader } from '../config/loader.js';
6
+ import { resolvePluginSpecifiers } from '../plugins/resolver.js';
6
7
  import { renderSummaryView } from '../reporter/summary-view.js';
7
8
  import { Reporter } from '../runner/reporter.js';
8
9
  import { RustCoreBridge } from '../runner/bridge.js';
@@ -94,6 +95,7 @@ export async function testCommand(options = {}) {
94
95
  headless = options.headless;
95
96
  }
96
97
  // Fallback to spectra.config.ts if options were not explicitly provided on CLI
98
+ let pluginSpecifiers = [];
97
99
  try {
98
100
  const config = await ConfigLoader.loadConfig(cwd);
99
101
  if (stepDelayMs === undefined && config?.webConfig?.stepDelay !== undefined) {
@@ -104,6 +106,7 @@ export async function testCommand(options = {}) {
104
106
  if (!options.headed && options.headless === undefined && config?.webConfig?.headless !== undefined) {
105
107
  headless = String(config.webConfig.headless) !== 'false' && Boolean(config.webConfig.headless);
106
108
  }
109
+ pluginSpecifiers = resolvePluginSpecifiers(cwd, config?.executionConfig?.plugins);
107
110
  }
108
111
  catch {
109
112
  /* ignore config load failures */
@@ -120,6 +123,7 @@ export async function testCommand(options = {}) {
120
123
  const bundleServer = await reactPkg.startBundleServer({
121
124
  testRoot: cwd,
122
125
  stepDelayMs: stepDelayMs ?? 0,
126
+ plugins: pluginSpecifiers,
123
127
  });
124
128
  let ok = false;
125
129
  try {
@@ -11,6 +11,9 @@ export function startWatcher(cwd, debounceMs = 60) {
11
11
  TypeGenerator.syncEnvDeclaration(cwd).catch((err) => {
12
12
  console.error(chalk.red(`✖ Error generating initial env.d.ts: ${err?.message}`));
13
13
  });
14
+ TypeGenerator.syncPluginDeclarations(cwd).catch((err) => {
15
+ console.error(chalk.red(`✖ Error generating initial plugin declarations: ${err?.message}`));
16
+ });
14
17
  let debounceTimer = null;
15
18
  const triggerRegen = (filePath, eventType) => {
16
19
  if (debounceTimer)
@@ -19,6 +22,7 @@ export function startWatcher(cwd, debounceMs = 60) {
19
22
  try {
20
23
  TypeGenerator.generateAll(cwd);
21
24
  await TypeGenerator.syncEnvDeclaration(cwd);
25
+ await TypeGenerator.syncPluginDeclarations(cwd);
22
26
  const time = new Date().toLocaleTimeString();
23
27
  const rel = path.relative(cwd, filePath) || filePath;
24
28
  console.log(chalk.gray(`[${time}] `) + chalk.cyan(`✨ Updated ambient types `) + chalk.gray(`(${eventType}: ${rel})`));
@@ -117,6 +117,10 @@ export class ConfigLoader {
117
117
  loaded.executionConfig?.environmentVariables ??
118
118
  DEFAULT_CONFIG_DATA.executionConfig?.environmentVariables ??
119
119
  {},
120
+ plugins: overrides?.executionConfig?.plugins ??
121
+ loaded.executionConfig?.plugins ??
122
+ DEFAULT_CONFIG_DATA.executionConfig?.plugins ??
123
+ [],
120
124
  },
121
125
  };
122
126
  }
@@ -373,6 +373,16 @@ export interface ExecutionConfig {
373
373
  * (e.g. `{ API_SECRET_KEY: "..." }`).
374
374
  */
375
375
  environmentVariables?: Record<string, string>;
376
+ /**
377
+ * TestSpectra plugins to load before tests run. Each entry is a module specifier — a package
378
+ * name (e.g. `"@myorg/spectra-button-plugin"`) or a path relative to the project root — whose
379
+ * default export is a `SpectraPlugin` (`{ name, setup }`) or a bare `(spectra) => void` install
380
+ * function. Plugins extend the `Spectra` DSL via `Spectra.registerCommand()`.
381
+ *
382
+ * Consumer projects must also wire the plugin's ambient types themselves (via TypeScript module
383
+ * augmentation of `SpectraStatic`); the type generator does not discover external plugins.
384
+ */
385
+ plugins?: string[];
376
386
  }
377
387
  /**
378
388
  * Root configuration data structure matching TestSpectra project schema.
@@ -50,6 +50,7 @@ export class TsConfigGenerator {
50
50
  },
51
51
  include: [
52
52
  '../types/web.d.ts',
53
+ '../types/plugins/web.d.ts',
53
54
  '../types/fixtures.d.ts',
54
55
  '../../**/specs/**/*.ts',
55
56
  '../../**/specs/**/*.tsx',
@@ -90,6 +91,7 @@ export class TsConfigGenerator {
90
91
  },
91
92
  include: [
92
93
  '../types/mobile.d.ts',
94
+ '../types/plugins/mobile.d.ts',
93
95
  '../types/fixtures.d.ts',
94
96
  '../../**/specs/**/*.ts',
95
97
  '../../**/specs/**/*.tsx',
@@ -130,6 +132,7 @@ export class TsConfigGenerator {
130
132
  },
131
133
  include: [
132
134
  '../types/android.d.ts',
135
+ '../types/plugins/android.d.ts',
133
136
  '../types/fixtures.d.ts',
134
137
  '../../**/specs/**/*.ts',
135
138
  '../../**/specs/**/*.tsx',
@@ -170,6 +173,7 @@ export class TsConfigGenerator {
170
173
  },
171
174
  include: [
172
175
  '../types/ios.d.ts',
176
+ '../types/plugins/ios.d.ts',
173
177
  '../types/fixtures.d.ts',
174
178
  '../../**/specs/**/*.ts',
175
179
  '../../**/specs/**/*.tsx',
@@ -210,6 +214,7 @@ export class TsConfigGenerator {
210
214
  },
211
215
  include: [
212
216
  '../types/common.d.ts',
217
+ '../types/plugins/common.d.ts',
213
218
  '../types/fixtures.d.ts',
214
219
  '../../**/page-objects/**/common.ts',
215
220
  '../../**/page-objects/**/common.tsx',
@@ -244,8 +249,21 @@ export class TsConfigGenerator {
244
249
  const relRuns = path.relative(subConfigDir, path.join(options.cwd, '.testspectra', 'runs')).replace(/\\/g, '/');
245
250
  const relCache = path.relative(subConfigDir, path.join(options.cwd, '.testspectra', 'cache')).replace(/\\/g, '/');
246
251
  const platformDts = `${relTypesDir}/${platform}.d.ts`;
252
+ // Plugin declarations are workspace-wide and live only at the root types dir
253
+ // (`.testspectra/types/plugins/<platform>.d.ts`), unlike scoped fixtures/platform files —
254
+ // so reference them relative to the root, not to this scope's own types dir.
255
+ const pluginDts = path
256
+ .relative(subConfigDir, path.join(options.cwd, '.testspectra', 'types', 'plugins', `${platform}.d.ts`))
257
+ .replace(/\\/g, '/');
247
258
  const fixturesDts = `${relTypesDir}/fixtures.d.ts`;
248
- const include = [platformDts, fixturesDts, `${relScope}/**/*.ts`, `${relScope}/**/*.tsx`, relSpectraConfig];
259
+ const include = [
260
+ platformDts,
261
+ pluginDts,
262
+ fixturesDts,
263
+ `${relScope}/**/*.ts`,
264
+ `${relScope}/**/*.tsx`,
265
+ relSpectraConfig,
266
+ ];
249
267
  const baseExcludes = [relNodeModules, relDist, relReports, relRuns, relCache];
250
268
  const stemMap = {
251
269
  web: ['android', 'ios', 'mobile'],
@@ -38,6 +38,18 @@ export declare class TypeGenerator {
38
38
  * (which must stay synchronous) because reading the config file is unavoidably async.
39
39
  */
40
40
  static syncEnvDeclaration(cwd: string): Promise<void>;
41
+ /**
42
+ * Reloads `spectra.config.ts` and refreshes `.testspectra/types/plugins/<platform>.d.ts` with the
43
+ * platform-appropriate plugin type entries. Mirrors `syncEnvDeclaration` (async, config-driven):
44
+ * each configured plugin package exposes platform-stem subpath exports (`./web`, `./android`,
45
+ * `./ios`, `./mobile`, `./common`) containing `SpectraStatic` module augmentations, and this
46
+ * method emits, per platform, imports for the stems in that platform's hierarchy.
47
+ *
48
+ * A plugin with no platform-stem subpaths is treated as `common` (applies to every platform).
49
+ * These files are referenced from each generated `<platform>.d.ts` (see
50
+ * `generateDeclarationsForScope`); `writeDeclarationFiles` guarantees they exist first.
51
+ */
52
+ static syncPluginDeclarations(cwd: string): Promise<void>;
41
53
  /**
42
54
  * Discovers all feature directories and shared library directories.
43
55
  * Returns a map of scope -> base directory
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { createRequire } from 'node:module';
3
4
  import { ConfigLoader } from '../config/loader.js';
4
5
  import { TsConfigGenerator } from './tsconfig-generator.js';
5
6
  export const PLATFORM_HIERARCHY = {
@@ -194,6 +195,75 @@ export function isComponentTestingProject(cwd) {
194
195
  return false;
195
196
  }
196
197
  }
198
+ /** Platform stems a plugin may expose as `exports` subpaths. */
199
+ const PLUGIN_STEMS = ['web', 'android', 'ios', 'mobile', 'common'];
200
+ /**
201
+ * Reads the platform stems a plugin package exposes as subpath exports (`./web`, `./android`,
202
+ * `./ios`, `./mobile`, `./common`). A plugin with no such subpaths returns `[]` and is treated as
203
+ * a root-only (`common`) plugin by the caller.
204
+ */
205
+ function detectPluginStems(exportsField) {
206
+ if (!exportsField || typeof exportsField !== 'object')
207
+ return [];
208
+ const keys = Object.keys(exportsField);
209
+ return PLUGIN_STEMS.filter((stem) => keys.includes(`./${stem}`));
210
+ }
211
+ function walkUpToPackageJson(filePath) {
212
+ let dir = path.dirname(filePath);
213
+ while (dir !== path.dirname(dir)) {
214
+ const candidate = path.join(dir, 'package.json');
215
+ if (fs.existsSync(candidate))
216
+ return candidate;
217
+ dir = path.dirname(dir);
218
+ }
219
+ return null;
220
+ }
221
+ /**
222
+ * Resolves a plugin specifier to its `package.json` path, so the generator can read the package
223
+ * name and its platform-stem `exports`. Handles absolute/relative paths and bare package
224
+ * specifiers (via Node resolution, with a `node_modules/<spec>` fallback for ESM-only packages
225
+ * whose root export has no `require`/`default` condition).
226
+ */
227
+ function resolvePluginPackageJson(cwd, specifier) {
228
+ if (path.isAbsolute(specifier) || specifier.startsWith('.')) {
229
+ const resolved = path.resolve(cwd, specifier);
230
+ if (fs.existsSync(resolved)) {
231
+ if (fs.statSync(resolved).isDirectory()) {
232
+ const pkg = path.join(resolved, 'package.json');
233
+ return fs.existsSync(pkg) ? pkg : null;
234
+ }
235
+ return walkUpToPackageJson(resolved);
236
+ }
237
+ return null;
238
+ }
239
+ try {
240
+ const req = createRequire(path.join(cwd, 'package.json'));
241
+ return walkUpToPackageJson(req.resolve(specifier));
242
+ }
243
+ catch {
244
+ const fallback = path.join(cwd, 'node_modules', specifier, 'package.json');
245
+ return fs.existsSync(fallback) ? fallback : null;
246
+ }
247
+ }
248
+ /**
249
+ * Resolves each configured plugin to its package name + available platform stems.
250
+ * Unresolvable plugins are skipped (the runtime loader reports them separately).
251
+ */
252
+ function resolvePluginStems(cwd, plugins) {
253
+ const infos = [];
254
+ for (const specifier of plugins) {
255
+ const pkgPath = resolvePluginPackageJson(cwd, specifier);
256
+ if (!pkgPath)
257
+ continue;
258
+ try {
259
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
260
+ const name = typeof pkg.name === 'string' && pkg.name ? pkg.name : specifier;
261
+ infos.push({ name, stems: detectPluginStems(pkg.exports) });
262
+ }
263
+ catch { }
264
+ }
265
+ return infos;
266
+ }
197
267
  export class TypeGenerator {
198
268
  /**
199
269
  * Main entry point to generate all ambient type declarations.
@@ -220,6 +290,49 @@ export class TypeGenerator {
220
290
  catch { }
221
291
  fs.writeFileSync(path.join(rootTypesDir, 'env.d.ts'), this.generateEnvDeclaration(environmentVariables), 'utf-8');
222
292
  }
293
+ /**
294
+ * Reloads `spectra.config.ts` and refreshes `.testspectra/types/plugins/<platform>.d.ts` with the
295
+ * platform-appropriate plugin type entries. Mirrors `syncEnvDeclaration` (async, config-driven):
296
+ * each configured plugin package exposes platform-stem subpath exports (`./web`, `./android`,
297
+ * `./ios`, `./mobile`, `./common`) containing `SpectraStatic` module augmentations, and this
298
+ * method emits, per platform, imports for the stems in that platform's hierarchy.
299
+ *
300
+ * A plugin with no platform-stem subpaths is treated as `common` (applies to every platform).
301
+ * These files are referenced from each generated `<platform>.d.ts` (see
302
+ * `generateDeclarationsForScope`); `writeDeclarationFiles` guarantees they exist first.
303
+ */
304
+ static async syncPluginDeclarations(cwd) {
305
+ const rootTypesDir = path.join(cwd, '.testspectra', 'types');
306
+ const pluginsDir = path.join(rootTypesDir, 'plugins');
307
+ fs.mkdirSync(pluginsDir, { recursive: true });
308
+ let plugins = [];
309
+ try {
310
+ const config = await ConfigLoader.loadConfig(cwd);
311
+ plugins = config.executionConfig?.plugins ?? [];
312
+ }
313
+ catch { }
314
+ const infos = resolvePluginStems(cwd, plugins);
315
+ const platforms = ['web', 'android', 'ios', 'mobile', 'common'];
316
+ for (const platform of platforms) {
317
+ const hierarchy = PLATFORM_HIERARCHY[platform];
318
+ const specifiers = new Set();
319
+ for (const info of infos) {
320
+ if (info.stems.length === 0) {
321
+ // Root-only plugin: treated as `common`, so it applies to every platform.
322
+ specifiers.add(info.name);
323
+ continue;
324
+ }
325
+ for (const stem of hierarchy) {
326
+ if (info.stems.includes(stem))
327
+ specifiers.add(`${info.name}/${stem}`);
328
+ }
329
+ }
330
+ const content = specifiers.size > 0
331
+ ? `${[...specifiers].map((s) => `import '${s}';`).join('\n')}\n\nexport {};\n`
332
+ : `// No TestSpectra plugin augmentations for the ${platform} platform.\nexport {};\n`;
333
+ fs.writeFileSync(path.join(pluginsDir, `${platform}.d.ts`), content, 'utf-8');
334
+ }
335
+ }
223
336
  /**
224
337
  * Discovers all feature directories and shared library directories.
225
338
  * Returns a map of scope -> base directory
@@ -643,6 +756,19 @@ export class TypeGenerator {
643
756
  if (!fs.existsSync(envDtsPath)) {
644
757
  fs.writeFileSync(envDtsPath, this.generateEnvDeclaration({}), 'utf-8');
645
758
  }
759
+ // Plugin declaration files are filled in asynchronously by `syncPluginDeclarations` (reading
760
+ // `spectra.config.ts` is async), but the generated `<platform>.d.ts` files reference them via
761
+ // `/// <reference path>`. Guarantee they exist so a reference never points at a missing file.
762
+ const pluginsDir = path.join(rootTypesDir, 'plugins');
763
+ if (!fs.existsSync(pluginsDir)) {
764
+ fs.mkdirSync(pluginsDir, { recursive: true });
765
+ }
766
+ for (const platform of ['web', 'android', 'ios', 'mobile', 'common']) {
767
+ const pluginDts = path.join(pluginsDir, `${platform}.d.ts`);
768
+ if (!fs.existsSync(pluginDts)) {
769
+ fs.writeFileSync(pluginDts, `// No TestSpectra plugin augmentations for the ${platform} platform.\nexport {};\n`, 'utf-8');
770
+ }
771
+ }
646
772
  const scopes = this.getEntityScopes(cwd);
647
773
  const sharedScope = scopes.find((s) => s.isShared);
648
774
  const featureScopes = scopes.filter((s) => !s.isShared);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Resolves `executionConfig.plugins` module specifiers to absolute file paths so the worker
3
+ * runtime (Bun E2E worker or browser bundle server) can import them regardless of its own
4
+ * resolution root.
5
+ *
6
+ * - Absolute paths are passed through unchanged.
7
+ * - Relative paths are resolved against the project root.
8
+ * - Bare package specifiers are resolved through Node's resolver rooted at the project; if that
9
+ * fails (e.g. an ESM-only package with no `require`/`default` export condition), the original
10
+ * specifier is kept so the runtime can still attempt its own bare-specifier resolution.
11
+ */
12
+ export declare function resolvePluginSpecifiers(cwd: string, plugins?: string[]): string[];
@@ -0,0 +1,48 @@
1
+ import { createRequire } from 'node:module';
2
+ import path from 'node:path';
3
+ /**
4
+ * Resolves `executionConfig.plugins` module specifiers to absolute file paths so the worker
5
+ * runtime (Bun E2E worker or browser bundle server) can import them regardless of its own
6
+ * resolution root.
7
+ *
8
+ * - Absolute paths are passed through unchanged.
9
+ * - Relative paths are resolved against the project root.
10
+ * - Bare package specifiers are resolved through Node's resolver rooted at the project; if that
11
+ * fails (e.g. an ESM-only package with no `require`/`default` export condition), the original
12
+ * specifier is kept so the runtime can still attempt its own bare-specifier resolution.
13
+ */
14
+ export function resolvePluginSpecifiers(cwd, plugins) {
15
+ if (!plugins || plugins.length === 0)
16
+ return [];
17
+ let projectRequire;
18
+ try {
19
+ projectRequire = createRequire(path.join(cwd, 'package.json'));
20
+ }
21
+ catch {
22
+ projectRequire = undefined;
23
+ }
24
+ const resolved = [];
25
+ for (const specifier of plugins) {
26
+ if (typeof specifier !== 'string' || specifier.length === 0)
27
+ continue;
28
+ if (path.isAbsolute(specifier)) {
29
+ resolved.push(specifier);
30
+ continue;
31
+ }
32
+ if (specifier.startsWith('.')) {
33
+ resolved.push(path.resolve(cwd, specifier));
34
+ continue;
35
+ }
36
+ let found;
37
+ if (projectRequire) {
38
+ try {
39
+ found = projectRequire.resolve(specifier);
40
+ }
41
+ catch {
42
+ found = undefined;
43
+ }
44
+ }
45
+ resolved.push(found ?? specifier);
46
+ }
47
+ return resolved;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testspectra/cli",
3
- "version": "1.1.14",
3
+ "version": "1.2.0",
4
4
  "description": "TestSpectra Cross-Platform Test Runner CLI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,10 +25,10 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "@clack/prompts": "^1.7.0",
28
- "@testspectra/matchers": "^1.1.14",
29
- "@testspectra/react": "^1.1.14",
30
- "@testspectra/reporter": "^1.1.14",
31
- "@testspectra/skills": "^1.1.14",
28
+ "@testspectra/matchers": "^1.2.0",
29
+ "@testspectra/react": "^1.2.0",
30
+ "@testspectra/reporter": "^1.2.0",
31
+ "@testspectra/skills": "^1.2.0",
32
32
  "chalk": "^5.3.0",
33
33
  "commander": "^12.1.0",
34
34
  "cross-spawn": "^7.0.6",
@@ -39,9 +39,9 @@
39
39
  "zod": "^3.23.8"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@testspectra/cli-darwin-arm64": "1.1.14",
43
- "@testspectra/cli-linux-x64": "1.1.14",
44
- "@testspectra/cli-win32-x64": "1.1.14"
42
+ "@testspectra/cli-darwin-arm64": "1.2.0",
43
+ "@testspectra/cli-linux-x64": "1.2.0",
44
+ "@testspectra/cli-win32-x64": "1.2.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/cross-spawn": "^6.0.6",
@@ -17,8 +17,9 @@
17
17
  "postinstall": "spectra sync-types"
18
18
  },
19
19
  "devDependencies": {
20
- "@testspectra/cli": "^1.1.14",
21
- "@testspectra/matchers": "^1.1.14",
20
+ "@testspectra/cli": "^1.2.0",
21
+ "@testspectra/matchers": "^1.2.0",
22
+ "@testspectra/plugin-demo": "^1.2.0",
22
23
  "@types/node": "^20.14.0",
23
24
  "@wdio/cli": "^9.2.8",
24
25
  "@wdio/local-runner": "^9.2.8",
@@ -12,10 +12,17 @@ it("Verify button state, checkbox, radio and attributes", async () => {
12
12
  await MatchersPage.checkbox.shouldNotBeChecked();
13
13
  await MatchersPage.radio.shouldBeChecked();
14
14
 
15
- // Focus states
15
+ // Focus & blur states (element API). On mobile `focus()` clicks the field and `blur()` is a
16
+ // no-op (native apps have no DOM focus concept), so we only assert the focused transition.
16
17
  await MatchersPage.focusInput.shouldNotBeFocused();
17
- await MatchersPage.focusInput.click();
18
+ await MatchersPage.focusInput.focus();
18
19
  await MatchersPage.focusInput.shouldBeFocused();
20
+ await MatchersPage.focusInput.blur();
21
+
22
+ // Focus states (static Spectra API) mirror the element API on mobile.
23
+ await Spectra.focus(MatchersPage.focusInput);
24
+ await MatchersPage.focusInput.shouldBeFocused();
25
+ await Spectra.blur(MatchersPage.focusInput);
19
26
 
20
27
  // Class, Attribute, and CSS assertions
21
28
  await MatchersPage.activeTab.shouldHaveClass("is-active");
@@ -17,3 +17,4 @@ coverage:
17
17
  3. Assert terms checkbox is not checked
18
18
  4. Assert active tab has is-active class
19
19
  5. Assert navigation link has correct href attribute
20
+ 6. Focus and blur the focus input and assert focus state transitions
@@ -12,10 +12,18 @@ it("Verify button state, checkbox, radio and attributes", async () => {
12
12
  await MatchersPage.checkbox.shouldNotBeChecked();
13
13
  await MatchersPage.radio.shouldBeChecked();
14
14
 
15
- // Focus states
15
+ // Focus & blur states (element API)
16
16
  await MatchersPage.focusInput.shouldNotBeFocused();
17
- await MatchersPage.focusInput.click();
17
+ await MatchersPage.focusInput.focus();
18
18
  await MatchersPage.focusInput.shouldBeFocused();
19
+ await MatchersPage.focusInput.blur();
20
+ await MatchersPage.focusInput.shouldNotBeFocused();
21
+
22
+ // Focus & blur states (static Spectra API)
23
+ await Spectra.focus(MatchersPage.focusInput);
24
+ await MatchersPage.focusInput.shouldBeFocused();
25
+ await Spectra.blur(MatchersPage.focusInput);
26
+ await MatchersPage.focusInput.shouldNotBeFocused();
19
27
 
20
28
  // Class, Attribute, and CSS assertions
21
29
  await MatchersPage.activeTab.shouldHaveClass("is-active");
@@ -0,0 +1,16 @@
1
+ ---
2
+ id: TC-0001-plugin-commands
3
+ title: Verify plugin-registered custom Spectra commands
4
+ priority: Medium
5
+ caseType: Positive
6
+ status: ready-for-automation
7
+ coverage:
8
+ web: automated
9
+ ---
10
+
11
+ # Verify plugin-registered custom Spectra commands
12
+
13
+ ## Test Steps
14
+ 1. Navigate to the playground section
15
+ 2. Invoke the plugin-registered findButton command and assert its label
16
+ 3. Invoke the plugin-registered findComponent command and assert existence
@@ -0,0 +1,15 @@
1
+ it("Verify plugin-registered custom Spectra commands", async () => {
2
+ await Spectra.navigate("/#playground");
3
+
4
+ // Button-specific command registered by @testspectra/plugin-demo (common platform stem).
5
+ const enabledButton = Spectra.findButton("#demo-enabled-btn");
6
+ await enabledButton.shouldBeVisible();
7
+ await enabledButton.assertLabel("Enabled Button");
8
+ await enabledButton.press();
9
+
10
+ // Universal component command registered by the same plugin (common platform stem).
11
+ await Spectra.findComponent("#demo-email-input-pg").shouldExist();
12
+
13
+ // Web-only command registered by the plugin (web platform stem).
14
+ await Spectra.findByCss("#demo-email-input-pg").shouldExist();
15
+ });
@@ -0,0 +1,9 @@
1
+ ---
2
+ id: suite-plugin-demo
3
+ name: PluginDemo
4
+ description: Custom Spectra commands registered by an external TestSpectra plugin
5
+ executionOrder: 1.5
6
+ parallel: true
7
+ ---
8
+
9
+ # Suite: PluginDemo
@@ -52,5 +52,7 @@ export default defineConfig({
52
52
  normalResponseTime: "1000",
53
53
  monitoredDomains: [],
54
54
  environmentVariables: { DEMO_API_MODE: "sandbox" },
55
+ // Demo plugin: registers Spectra.findComponent() / Spectra.findButton() commands.
56
+ plugins: ["@testspectra/plugin-demo"],
55
57
  },
56
58
  });
@@ -12,8 +12,8 @@
12
12
  "postinstall": "spectra sync-types"
13
13
  },
14
14
  "devDependencies": {
15
- "@testspectra/cli": "^1.1.14",
16
- "@testspectra/matchers": "^1.1.14",
15
+ "@testspectra/cli": "^1.2.0",
16
+ "@testspectra/matchers": "^1.2.0",
17
17
  "@types/node": "^20.14.0",
18
18
  "@wdio/cli": "^9.2.8",
19
19
  "@wdio/local-runner": "^9.2.8",
@@ -23,6 +23,7 @@
23
23
  "@wdio/mocha-framework": "^9.2.8",
24
24
  "nx": "^20.8.4",
25
25
  "typescript": "^5.4.5",
26
- "webdriverio": "^9.2.8"
26
+ "webdriverio": "^9.2.8",
27
+ "@testspectra/plugin-demo": "^1.2.0"
27
28
  }
28
29
  }
@@ -14,9 +14,9 @@
14
14
  "postinstall": "spectra sync-types"
15
15
  },
16
16
  "devDependencies": {
17
- "@testspectra/cli": "^1.1.14",
18
- "@testspectra/matchers": "^1.1.14",
19
- "@testspectra/react": "^1.1.14",
17
+ "@testspectra/cli": "^1.2.0",
18
+ "@testspectra/matchers": "^1.2.0",
19
+ "@testspectra/react": "^1.2.0",
20
20
  "@types/node": "^20.14.0",
21
21
  "@types/react": "^18.3.3",
22
22
  "@types/react-dom": "^18.3.0",