@testspectra/cli 1.1.14 → 1.2.1
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/run.js +5 -0
- package/dist/commands/sync-types.js +1 -0
- package/dist/commands/test.js +4 -0
- package/dist/commands/watch.js +4 -0
- package/dist/config/loader.js +4 -0
- package/dist/config/schema.d.ts +10 -0
- package/dist/generator/tsconfig-generator.js +19 -1
- package/dist/generator/type-generator.d.ts +12 -0
- package/dist/generator/type-generator.js +126 -0
- package/dist/plugins/resolver.d.ts +12 -0
- package/dist/plugins/resolver.js +48 -0
- package/package.json +8 -8
- package/templates/default/package.json +3 -2
- package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/mobile.test.ts +9 -2
- package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/spec.md +1 -0
- package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/web.test.ts +10 -2
- package/templates/default/specs/PluginDemo/TC-0001-plugin-commands/spec.md +16 -0
- package/templates/default/specs/PluginDemo/TC-0001-plugin-commands/web.test.ts +15 -0
- package/templates/default/specs/PluginDemo/suite.md +9 -0
- package/templates/default/spectra.config.ts +2 -0
- package/templates/nx/package.json +4 -3
- package/templates/react-component/package.json +3 -3
- package/templates/react-component/specs/ButtonComponent/TC-0006-button-bounds-and-parent/spec.md +21 -0
- package/templates/react-component/specs/ButtonComponent/TC-0006-button-bounds-and-parent/web.test.tsx +25 -0
- package/templates/react-component/specs/ButtonComponent/TC-0007-button-wait-and-remove/spec.md +20 -0
- package/templates/react-component/specs/ButtonComponent/TC-0007-button-wait-and-remove/web.test.tsx +18 -0
- package/templates/react-component/specs/InputComponent/TC-0009-input-bounds/spec.md +20 -0
- package/templates/react-component/specs/InputComponent/TC-0009-input-bounds/web.test.tsx +15 -0
package/dist/commands/run.js
CHANGED
|
@@ -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) {
|
package/dist/commands/test.js
CHANGED
|
@@ -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 {
|
package/dist/commands/watch.js
CHANGED
|
@@ -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})`));
|
package/dist/config/loader.js
CHANGED
|
@@ -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
|
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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 = [
|
|
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
|
|
3
|
+
"version": "1.2.1",
|
|
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
|
|
29
|
-
"@testspectra/react": "^1.1
|
|
30
|
-
"@testspectra/reporter": "^1.1
|
|
31
|
-
"@testspectra/skills": "^1.1
|
|
28
|
+
"@testspectra/matchers": "^1.2.1",
|
|
29
|
+
"@testspectra/react": "^1.2.1",
|
|
30
|
+
"@testspectra/reporter": "^1.2.1",
|
|
31
|
+
"@testspectra/skills": "^1.2.1",
|
|
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
|
|
43
|
-
"@testspectra/cli-linux-x64": "1.1
|
|
44
|
-
"@testspectra/cli-win32-x64": "1.1
|
|
42
|
+
"@testspectra/cli-darwin-arm64": "1.2.1",
|
|
43
|
+
"@testspectra/cli-linux-x64": "1.2.1",
|
|
44
|
+
"@testspectra/cli-win32-x64": "1.2.1"
|
|
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
|
|
21
|
-
"@testspectra/matchers": "^1.1
|
|
20
|
+
"@testspectra/cli": "^1.2.1",
|
|
21
|
+
"@testspectra/matchers": "^1.2.1",
|
|
22
|
+
"@testspectra/plugin-demo": "^1.2.1",
|
|
22
23
|
"@types/node": "^20.14.0",
|
|
23
24
|
"@wdio/cli": "^9.2.8",
|
|
24
25
|
"@wdio/local-runner": "^9.2.8",
|
package/templates/default/specs/ElementMatchers/TC-0004-states-and-attributes/mobile.test.ts
CHANGED
|
@@ -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.
|
|
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");
|
|
@@ -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.
|
|
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
|
+
});
|
|
@@ -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
|
|
16
|
-
"@testspectra/matchers": "^1.1
|
|
15
|
+
"@testspectra/cli": "^1.2.1",
|
|
16
|
+
"@testspectra/matchers": "^1.2.1",
|
|
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.1"
|
|
27
28
|
}
|
|
28
29
|
}
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"postinstall": "spectra sync-types"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@testspectra/cli": "^1.1
|
|
18
|
-
"@testspectra/matchers": "^1.1
|
|
19
|
-
"@testspectra/react": "^1.1
|
|
17
|
+
"@testspectra/cli": "^1.2.1",
|
|
18
|
+
"@testspectra/matchers": "^1.2.1",
|
|
19
|
+
"@testspectra/react": "^1.2.1",
|
|
20
20
|
"@types/node": "^20.14.0",
|
|
21
21
|
"@types/react": "^18.3.3",
|
|
22
22
|
"@types/react-dom": "^18.3.0",
|
package/templates/react-component/specs/ButtonComponent/TC-0006-button-bounds-and-parent/spec.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: TC-0006
|
|
3
|
+
title: Verify button bounds and parent element access
|
|
4
|
+
priority: Medium
|
|
5
|
+
caseType: Positive
|
|
6
|
+
status: automated
|
|
7
|
+
coverage:
|
|
8
|
+
web: automated
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# TC-0006: Button Bounds and Parent Access
|
|
12
|
+
|
|
13
|
+
Verify that `Spectra.get()` exposes element bounds (position + size), can navigate up the DOM tree via `parent()`, and can locate an ancestor via `closest()`.
|
|
14
|
+
|
|
15
|
+
## Test Steps
|
|
16
|
+
|
|
17
|
+
1. Mount `<div><Button label="Bounds Test" /></div>`.
|
|
18
|
+
2. Get the button via `Spectra.get('~button')` and read its bounds.
|
|
19
|
+
3. Verify `bounds.width` and `bounds.height` are greater than 0, and `bounds.x` / `bounds.y` are numbers.
|
|
20
|
+
4. Call `parent()` and verify the parent element is visible.
|
|
21
|
+
5. Call `closest('div')` and verify a matching ancestor is returned.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Button } from '../../../src/components/Button/Button.js';
|
|
2
|
+
import { withProviders } from '../../../src/test-utils.js';
|
|
3
|
+
|
|
4
|
+
it('Verify button bounds and parent element access', async () => {
|
|
5
|
+
await Spectra.mount(withProviders(
|
|
6
|
+
<div><Button label="Bounds Test" /></div>,
|
|
7
|
+
));
|
|
8
|
+
|
|
9
|
+
const button = Spectra.get('~button');
|
|
10
|
+
|
|
11
|
+
// getBounds returns position + size
|
|
12
|
+
const bounds = await button.getBounds();
|
|
13
|
+
expect(bounds.width).toBeGreaterThan(0);
|
|
14
|
+
expect(bounds.height).toBeGreaterThan(0);
|
|
15
|
+
expect(typeof bounds.x).toBe('number');
|
|
16
|
+
expect(typeof bounds.y).toBe('number');
|
|
17
|
+
|
|
18
|
+
// parent() navigates up the DOM tree
|
|
19
|
+
const parent = button.parent();
|
|
20
|
+
await parent.shouldBeVisible();
|
|
21
|
+
|
|
22
|
+
// closest() finds matching ancestor
|
|
23
|
+
const wrapper = await button.closest('div');
|
|
24
|
+
expect(wrapper).not.toBeNull();
|
|
25
|
+
});
|
package/templates/react-component/specs/ButtonComponent/TC-0007-button-wait-and-remove/spec.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: TC-0007
|
|
3
|
+
title: Verify button wait states and element removal detection
|
|
4
|
+
priority: Medium
|
|
5
|
+
caseType: Positive
|
|
6
|
+
status: automated
|
|
7
|
+
coverage:
|
|
8
|
+
web: automated
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# TC-0007: Button Wait States
|
|
12
|
+
|
|
13
|
+
Verify that a mounted button satisfies explicit wait conditions such as `waitForClickable()` before asserting visibility.
|
|
14
|
+
|
|
15
|
+
## Test Steps
|
|
16
|
+
|
|
17
|
+
1. Mount `<div><Button label="Wait Test" /></div>`.
|
|
18
|
+
2. Get the button via `Spectra.get('~button')`.
|
|
19
|
+
3. Call `waitForClickable({ timeout: 3000 })` and verify it resolves to `true`.
|
|
20
|
+
4. Verify the button is visible.
|
package/templates/react-component/specs/ButtonComponent/TC-0007-button-wait-and-remove/web.test.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Button } from '../../../src/components/Button/Button.js';
|
|
2
|
+
import { withProviders } from '../../../src/test-utils.js';
|
|
3
|
+
|
|
4
|
+
it('Verify button wait states and element removal detection', async () => {
|
|
5
|
+
await Spectra.mount(withProviders(
|
|
6
|
+
<div><Button label="Wait Test" /></div>,
|
|
7
|
+
));
|
|
8
|
+
|
|
9
|
+
const button = Spectra.get('~button');
|
|
10
|
+
|
|
11
|
+
// waitForClickable — button should be clickable
|
|
12
|
+
const clickable = await button.waitForClickable({ timeout: 3000 });
|
|
13
|
+
expect(clickable).toBe(true);
|
|
14
|
+
|
|
15
|
+
// waitForUndisplayed on hidden element
|
|
16
|
+
// waitForElementRemoved would need a dynamically removed element — skip for static mount
|
|
17
|
+
await button.shouldBeVisible();
|
|
18
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: TC-0009
|
|
3
|
+
title: Verify input bounds and closest ancestor
|
|
4
|
+
priority: Medium
|
|
5
|
+
caseType: Positive
|
|
6
|
+
status: automated
|
|
7
|
+
coverage:
|
|
8
|
+
web: automated
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# TC-0009: Input Bounds and Closest Ancestor
|
|
12
|
+
|
|
13
|
+
Verify that `Spectra.get('input')` exposes element bounds (position + size) and can locate a matching ancestor via `closest()`.
|
|
14
|
+
|
|
15
|
+
## Test Steps
|
|
16
|
+
|
|
17
|
+
1. Mount `<Input placeholder="Bounds check" />`.
|
|
18
|
+
2. Get the input via `Spectra.get('input')` and read its bounds.
|
|
19
|
+
3. Verify `bounds.width` and `bounds.height` are greater than 0.
|
|
20
|
+
4. Call `closest('div')` and verify a matching ancestor is returned.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Input } from '../../../src/components/Input/Input.js';
|
|
2
|
+
import { withProviders } from '../../../src/test-utils.js';
|
|
3
|
+
|
|
4
|
+
it('Verify input bounds and closest ancestor', async () => {
|
|
5
|
+
await Spectra.mount(withProviders(<Input placeholder="Bounds check" />));
|
|
6
|
+
|
|
7
|
+
const input = Spectra.get('input');
|
|
8
|
+
|
|
9
|
+
const bounds = await input.getBounds();
|
|
10
|
+
expect(bounds.width).toBeGreaterThan(0);
|
|
11
|
+
expect(bounds.height).toBeGreaterThan(0);
|
|
12
|
+
|
|
13
|
+
const closest = await input.closest('div');
|
|
14
|
+
expect(closest).not.toBeNull();
|
|
15
|
+
});
|