@testspectra/cli 1.1.0-rc.0 → 1.1.0-rc.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/bin/.testspectra-runner.hash +1 -0
- package/bin/testspectra-runner +0 -0
- package/demo-app/assets/index-DOtRypCa.js +154 -0
- package/demo-app/index.html +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/__tests__/run.test.js +9 -13
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +6 -1
- package/dist/commands/run.js +1 -0
- package/dist/commands/sync-types.js +1 -0
- package/dist/commands/test.d.ts +17 -0
- package/dist/commands/test.js +76 -0
- package/dist/commands/watch.js +9 -2
- package/dist/config/loader.js +1 -1
- package/dist/config/schema.d.ts +5 -109
- package/dist/config/schema.js +1 -16
- package/dist/generator/__tests__/tsconfig-isolation.test.js +6 -2
- package/dist/generator/tsconfig-generator.js +42 -7
- package/dist/generator/type-generator.d.ts +24 -1
- package/dist/generator/type-generator.js +104 -8
- package/dist/index.js +15 -1
- package/dist/linter/__tests__/component-test-imports.test.d.ts +1 -0
- package/dist/linter/__tests__/component-test-imports.test.js +115 -0
- package/dist/linter/extractor.d.ts +1 -0
- package/dist/linter/extractor.js +11 -1
- package/dist/linter/schemas/spec.schema.d.ts +12 -12
- package/dist/linter/schemas/suite.schema.d.ts +8 -8
- package/dist/linter/spec-linter.js +38 -3
- package/dist/reporter/semantic-formatter.js +17 -2
- package/dist/reporter/types.d.ts +1 -1
- package/dist/runner/reporter.js +7 -3
- package/dist/utils/api-server.js +46 -1
- package/package.json +4 -3
- package/templates/default/package.json +2 -2
- package/templates/default/page-objects/AuthPage/mobile.ts +5 -0
- package/templates/default/page-objects/AuthPage/web.ts +10 -0
- package/templates/default/page-objects/MatchersPage/mobile.ts +13 -0
- package/templates/default/page-objects/MatchersPage/web.ts +5 -0
- package/templates/default/page-objects/PlaygroundPage/mobile.ts +6 -0
- package/templates/default/specs/ApiInterception/TC-0012-direct-and-late-response/common.test.ts +6 -9
- package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +7 -0
- package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/spec.md +19 -0
- package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/web.test.ts +9 -0
- package/templates/default/specs/Authentication/suite.md +9 -0
- package/templates/default/specs/BrowserContext/TC-0022-history-navigation/mobile.test.ts +17 -0
- package/templates/default/specs/BrowserContext/TC-0022-history-navigation/spec.md +28 -0
- package/templates/default/specs/BrowserContext/TC-0022-history-navigation/web.test.ts +26 -0
- package/templates/default/specs/ContentMatchers/TC-0020-scoped-element-access/common.test.ts +14 -0
- package/templates/default/specs/ContentMatchers/TC-0020-scoped-element-access/spec.md +21 -0
- package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/common.test.ts +5 -0
- package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md +16 -0
- package/templates/default/specs/EnvironmentConfig/suite.md +9 -0
- package/templates/default/spectra.config.ts +1 -16
- package/templates/default/support/actions/seedSession/mobile.action.ts +9 -0
- package/templates/default/support/actions/seedSession/web.action.ts +13 -0
- package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-shm +0 -0
- package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-wal +0 -0
- package/templates/nx/.nx/workspace-data/d/daemon.log +1060 -0
- package/templates/nx/.nx/workspace-data/d/server-process.json +1 -1
- package/templates/nx/.nx/workspace-data/file-map.json +116 -104
- package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
- package/templates/nx/package.json +2 -2
- package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/common.test.ts +5 -0
- package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md +16 -0
- package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/suite.md +9 -0
- package/templates/nx/spectra.config.ts +1 -16
- package/templates/react-component/.cursorrules +17 -0
- package/templates/react-component/.vscode/extensions.json +5 -0
- package/templates/react-component/CLAUDE.md +14 -0
- package/templates/react-component/README.md +69 -0
- package/templates/react-component/fixtures/component-mock.json +12 -0
- package/templates/react-component/global-hooks/after/web.hook.ts +3 -0
- package/templates/react-component/global-hooks/before/web.hook.ts +3 -0
- package/templates/react-component/package.json +27 -0
- package/templates/react-component/page-objects/BadgeComponent/web.ts +23 -0
- package/templates/react-component/page-objects/ButtonComponent/web.ts +23 -0
- package/templates/react-component/page-objects/InputComponent/web.ts +13 -0
- package/templates/react-component/specs/BadgeComponent/TC-0002-badge-severities-and-dot/spec.md +18 -0
- package/templates/react-component/specs/BadgeComponent/TC-0002-badge-severities-and-dot/web.test.tsx +11 -0
- package/templates/react-component/specs/BadgeComponent/TC-0006-badge-success-with-dot/spec.md +18 -0
- package/templates/react-component/specs/BadgeComponent/TC-0006-badge-success-with-dot/web.test.tsx +10 -0
- package/templates/react-component/specs/BadgeComponent/TC-0007-badge-by-severity-lookup/spec.md +18 -0
- package/templates/react-component/specs/BadgeComponent/TC-0007-badge-by-severity-lookup/web.test.tsx +16 -0
- package/templates/react-component/specs/BadgeComponent/suite.md +12 -0
- package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/spec.md +19 -0
- package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/web.test.tsx +28 -0
- package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/spec.md +19 -0
- package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/web.test.tsx +22 -0
- package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/spec.md +18 -0
- package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/web.test.tsx +8 -0
- package/templates/react-component/specs/ButtonComponent/suite.md +12 -0
- package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/spec.md +20 -0
- package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/web.test.tsx +28 -0
- package/templates/react-component/specs/InputComponent/TC-0008-disabled-input-not-editable/spec.md +18 -0
- package/templates/react-component/specs/InputComponent/TC-0008-disabled-input-not-editable/web.test.tsx +9 -0
- package/templates/react-component/specs/InputComponent/suite.md +12 -0
- package/templates/react-component/spectra.config.ts +27 -0
- package/templates/react-component/src/components/Badge/Badge.tsx +60 -0
- package/templates/react-component/src/components/Button/Button.tsx +57 -0
- package/templates/react-component/src/components/Input/Input.tsx +41 -0
- package/templates/react-component/src/test-utils.tsx +23 -0
- package/templates/react-component/support/actions/fillInputField/web.action.ts +9 -0
- package/templates/react-component/support/steps/verifyButtonState/web.step.ts +12 -0
- package/templates/react-component/tsconfig.json +8 -0
- package/demo-app/assets/index-BGllUp7o.js +0 -140
|
@@ -14,37 +14,37 @@ export declare const SuiteFrontmatterSchema: z.ZodEffects<z.ZodObject<{
|
|
|
14
14
|
tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
15
15
|
}, "strip", z.ZodTypeAny, {
|
|
16
16
|
id: string;
|
|
17
|
+
status?: "active" | "draft" | "deprecated" | undefined;
|
|
17
18
|
title?: string | undefined;
|
|
18
|
-
status?: "draft" | "deprecated" | "active" | undefined;
|
|
19
|
-
tags?: string[] | undefined;
|
|
20
19
|
name?: string | undefined;
|
|
20
|
+
tags?: string[] | undefined;
|
|
21
21
|
description?: string | undefined;
|
|
22
22
|
executionOrder?: number | undefined;
|
|
23
23
|
parallel?: boolean | undefined;
|
|
24
24
|
}, {
|
|
25
25
|
id: string;
|
|
26
|
+
status?: "active" | "draft" | "deprecated" | undefined;
|
|
26
27
|
title?: string | undefined;
|
|
27
|
-
status?: "draft" | "deprecated" | "active" | undefined;
|
|
28
|
-
tags?: string[] | undefined;
|
|
29
28
|
name?: string | undefined;
|
|
29
|
+
tags?: string[] | undefined;
|
|
30
30
|
description?: string | undefined;
|
|
31
31
|
executionOrder?: number | undefined;
|
|
32
32
|
parallel?: boolean | undefined;
|
|
33
33
|
}>, {
|
|
34
34
|
id: string;
|
|
35
|
+
status?: "active" | "draft" | "deprecated" | undefined;
|
|
35
36
|
title?: string | undefined;
|
|
36
|
-
status?: "draft" | "deprecated" | "active" | undefined;
|
|
37
|
-
tags?: string[] | undefined;
|
|
38
37
|
name?: string | undefined;
|
|
38
|
+
tags?: string[] | undefined;
|
|
39
39
|
description?: string | undefined;
|
|
40
40
|
executionOrder?: number | undefined;
|
|
41
41
|
parallel?: boolean | undefined;
|
|
42
42
|
}, {
|
|
43
43
|
id: string;
|
|
44
|
+
status?: "active" | "draft" | "deprecated" | undefined;
|
|
44
45
|
title?: string | undefined;
|
|
45
|
-
status?: "draft" | "deprecated" | "active" | undefined;
|
|
46
|
-
tags?: string[] | undefined;
|
|
47
46
|
name?: string | undefined;
|
|
47
|
+
tags?: string[] | undefined;
|
|
48
48
|
description?: string | undefined;
|
|
49
49
|
executionOrder?: number | undefined;
|
|
50
50
|
parallel?: boolean | undefined;
|
|
@@ -6,6 +6,25 @@ import { isInsideSharedLibrary } from './discovery.js';
|
|
|
6
6
|
import { parseMarkdownFrontmatter } from './parser.js';
|
|
7
7
|
import { SpecFrontmatterSchema, validateSpecSections } from './schemas/spec.schema.js';
|
|
8
8
|
import { SuiteFrontmatterSchema, validateSuiteSections } from './schemas/suite.schema.js';
|
|
9
|
+
/**
|
|
10
|
+
* Resolves an import specifier against the importing file's directory and checks whether it
|
|
11
|
+
* reaches into an ambient-global-backed directory (Page/Component Objects, Steps, Fixtures) —
|
|
12
|
+
* the only imports still forbidden in `.tsx` component test files. Everything else (React,
|
|
13
|
+
* component source under test, npm packages, test-utils helpers) is allowed in `.tsx`, unlike
|
|
14
|
+
* the fully zero-import `.ts` E2E spec contract.
|
|
15
|
+
*
|
|
16
|
+
* See docs/v2/features/component-testing/react/mount-convention.md §"Static Imports Are Allowed
|
|
17
|
+
* Here" for the rationale.
|
|
18
|
+
*/
|
|
19
|
+
function resolvesToAmbientGlobalDir(specifier, testFilePath) {
|
|
20
|
+
if (!specifier.startsWith('.'))
|
|
21
|
+
return false; // bare package specifiers (react, etc.) are fine
|
|
22
|
+
const resolved = path.normalize(path.join(path.dirname(testFilePath), specifier));
|
|
23
|
+
const segments = resolved.split(path.sep);
|
|
24
|
+
return (segments.includes('page-objects') ||
|
|
25
|
+
segments.includes('fixtures') ||
|
|
26
|
+
(segments.includes('support') && segments.includes('steps')));
|
|
27
|
+
}
|
|
9
28
|
export class SpecLinter {
|
|
10
29
|
/**
|
|
11
30
|
* Validates a single spec.md file.
|
|
@@ -105,11 +124,17 @@ export class SpecLinter {
|
|
|
105
124
|
companionTestFiles = entries
|
|
106
125
|
.filter((e) => e.isFile() &&
|
|
107
126
|
(e.name.endsWith('.test.ts') ||
|
|
127
|
+
e.name.endsWith('.test.tsx') ||
|
|
108
128
|
e.name.endsWith('.web.ts') ||
|
|
129
|
+
e.name.endsWith('.web.tsx') ||
|
|
109
130
|
e.name.endsWith('.mobile.ts') ||
|
|
131
|
+
e.name.endsWith('.mobile.tsx') ||
|
|
110
132
|
e.name.endsWith('.android.ts') ||
|
|
133
|
+
e.name.endsWith('.android.tsx') ||
|
|
111
134
|
e.name.endsWith('.ios.ts') ||
|
|
112
|
-
e.name.endsWith('.
|
|
135
|
+
e.name.endsWith('.ios.tsx') ||
|
|
136
|
+
e.name.endsWith('.common.ts') ||
|
|
137
|
+
e.name.endsWith('.common.tsx')))
|
|
113
138
|
.map((e) => path.join(specDir, e.name));
|
|
114
139
|
}
|
|
115
140
|
catch { }
|
|
@@ -158,12 +183,22 @@ export class SpecLinter {
|
|
|
158
183
|
}
|
|
159
184
|
}
|
|
160
185
|
const symbols = extractSymbolsFromTestCode(testCode, testFilePath);
|
|
161
|
-
|
|
186
|
+
const isComponentTestFile = testFilePath.endsWith('.tsx');
|
|
187
|
+
// testspectra/no-manual-import: Manual import statement detected (must be zero-import).
|
|
188
|
+
// `.tsx` component test files (docs/v2/features/component-testing/react/) are exempt
|
|
189
|
+
// EXCEPT for imports that reach into ambient-global-backed directories (Page/Component
|
|
190
|
+
// Objects, Steps, Fixtures) — those stay forbidden so the ambient global is the single
|
|
191
|
+
// source of truth there too, exactly as in zero-import `.ts` E2E specs.
|
|
162
192
|
for (const imp of symbols.importStatements) {
|
|
193
|
+
if (isComponentTestFile && !resolvesToAmbientGlobalDir(imp.specifier, testFilePath)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
163
196
|
diagnostics.push({
|
|
164
197
|
code: 'testspectra/no-manual-import',
|
|
165
198
|
severity: 'error',
|
|
166
|
-
message:
|
|
199
|
+
message: isComponentTestFile
|
|
200
|
+
? `Manual import of "${imp.specifier}" is forbidden; Page/Component Objects, Steps, and Fixtures are ambient globals even in component test files`
|
|
201
|
+
: 'Manual imports forbidden; all TestSpectra APIs, Page Objects, Steps, and Fixtures are ambient globals',
|
|
167
202
|
filePath: testFilePath,
|
|
168
203
|
line: imp.line,
|
|
169
204
|
column: imp.column,
|
|
@@ -13,6 +13,17 @@ function extractTargetString(target) {
|
|
|
13
13
|
}
|
|
14
14
|
return String(target);
|
|
15
15
|
}
|
|
16
|
+
/** Renders a `ScopedSelector`'s `.parent` chain (produced by `element.get()`/`.getAll()`
|
|
17
|
+
* chaining) as `"parent" within "grandparent"`, nearest ancestor first — or null when the
|
|
18
|
+
* target isn't scoped. */
|
|
19
|
+
function extractWithinChain(target) {
|
|
20
|
+
if (!target || typeof target !== 'object' || !target.parent)
|
|
21
|
+
return null;
|
|
22
|
+
const chain = [];
|
|
23
|
+
for (let p = target.parent; p; p = p.parent)
|
|
24
|
+
chain.push(p.selector);
|
|
25
|
+
return chain.join('" within "');
|
|
26
|
+
}
|
|
16
27
|
function formatElementTarget(target) {
|
|
17
28
|
const sel = extractTargetString(target);
|
|
18
29
|
if (!sel)
|
|
@@ -20,7 +31,8 @@ function formatElementTarget(target) {
|
|
|
20
31
|
if (sel.startsWith('element "') || sel.startsWith('collection "')) {
|
|
21
32
|
return sel;
|
|
22
33
|
}
|
|
23
|
-
|
|
34
|
+
const within = extractWithinChain(target);
|
|
35
|
+
return within ? `element "${sel}" within "${within}"` : `element "${sel}"`;
|
|
24
36
|
}
|
|
25
37
|
function formatCollectionTarget(target) {
|
|
26
38
|
const sel = extractTargetString(target);
|
|
@@ -29,7 +41,8 @@ function formatCollectionTarget(target) {
|
|
|
29
41
|
if (sel.startsWith('collection "')) {
|
|
30
42
|
return sel;
|
|
31
43
|
}
|
|
32
|
-
|
|
44
|
+
const within = extractWithinChain(target);
|
|
45
|
+
return within ? `collection "${sel}" within "${within}"` : `collection "${sel}"`;
|
|
33
46
|
}
|
|
34
47
|
/**
|
|
35
48
|
* Transforms raw matcher assertions and element commands into grammatically correct English sentences.
|
|
@@ -83,6 +96,8 @@ export function formatAction(actionKey, target, args = {}) {
|
|
|
83
96
|
return `Intercept ${args.method ?? '{method}'} "${args.url ?? '{url}'}" mock rule registered`;
|
|
84
97
|
case 'clearCookies':
|
|
85
98
|
return `Clear all browser cookies`;
|
|
99
|
+
case 'setCookies':
|
|
100
|
+
return `Set cookies for "${args.url ?? '{url}'}"`;
|
|
86
101
|
case 'clearLocalStorage':
|
|
87
102
|
return `Clear browser localStorage`;
|
|
88
103
|
default:
|
package/dist/reporter/types.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type ActionKey = 'click' | 'doubleClick' | 'rightClick' | 'type' | 'clear' | 'select' | 'hover' | 'dragDrop' | 'scrollIntoView' | 'longPress' | 'waitForElement' | 'navigate' | 'back' | 'forward' | 'refresh' | 'setViewport' | 'wait' | 'pressKey' | 'scroll' | 'swipe' | 'intercept' | 'clearCookies' | 'clearLocalStorage';
|
|
1
|
+
export type ActionKey = 'click' | 'doubleClick' | 'rightClick' | 'type' | 'clear' | 'select' | 'hover' | 'dragDrop' | 'scrollIntoView' | 'longPress' | 'waitForElement' | 'navigate' | 'back' | 'forward' | 'refresh' | 'setViewport' | 'wait' | 'pressKey' | 'scroll' | 'swipe' | 'intercept' | 'clearCookies' | 'setCookies' | 'clearLocalStorage';
|
|
2
2
|
export type AssertionKey = 'be.visible' | 'not.be.visible' | 'exist' | 'not.exist' | 'be.clickable' | 'not.be.clickable' | 'be.enabled' | 'be.disabled' | 'be.checked' | 'not.be.checked' | 'be.selected' | 'not.be.selected' | 'be.focused' | 'not.be.focused' | 'have.text' | 'not.have.text' | 'contain.text' | 'not.contain.text' | 'have.value' | 'not.have.value' | 'contain.value' | 'not.contain.value' | 'have.attr' | 'not.have.attr' | 'have.class' | 'not.have.class' | 'have.css' | 'not.have.css' | 'have.length' | 'not.have.length' | 'have.length.greaterThan' | 'have.length.lessThan' | 'be.empty' | 'not.be.empty' | 'shouldHaveUrl' | 'shouldContainUrl' | 'shouldHaveTitle' | 'shouldContainTitle' | 'shouldBeLoaded' | 'shouldBePageLoaded' | 'shouldHaveNoConsoleErrors' | 'have.url' | 'contain.url' | 'have.title' | 'contain.title' | 'be.loaded' | 'be.pageLoaded' | 'have.noConsoleErrors';
|
package/dist/runner/reporter.js
CHANGED
|
@@ -19,6 +19,8 @@ function mapActionArgs(key, arr) {
|
|
|
19
19
|
return { durationMs: arr[0] };
|
|
20
20
|
case 'navigate':
|
|
21
21
|
return { url: arr[0] };
|
|
22
|
+
case 'setCookies':
|
|
23
|
+
return { url: arr[0] };
|
|
22
24
|
case 'setViewport':
|
|
23
25
|
return { width: arr[0], height: arr[1] };
|
|
24
26
|
case 'wait':
|
|
@@ -63,9 +65,11 @@ function mapAssertionArgs(key, arr) {
|
|
|
63
65
|
function parseAndFormat(cmdText) {
|
|
64
66
|
try {
|
|
65
67
|
const payload = JSON.parse(cmdText);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
// Passed through as-is (not pre-flattened to a bare string here) — `formatAction`/
|
|
69
|
+
// `formatAssertion` already do the same `.selector`/`.name`/`.target` fallback lookup
|
|
70
|
+
// themselves, and only the raw object retains `.parent` for a scoped (`element.get()`/
|
|
71
|
+
// `.getAll()`-chained) target, which they need to render the "within" phrasing.
|
|
72
|
+
const target = payload.target;
|
|
69
73
|
if (payload.type === 'action') {
|
|
70
74
|
return formatAction(payload.key, target, mapActionArgs(payload.key, payload.args || []));
|
|
71
75
|
}
|
package/dist/utils/api-server.js
CHANGED
|
@@ -38,6 +38,16 @@ const API_USERS = [
|
|
|
38
38
|
{ id: 3, name: 'Grace Hopper', username: 'grace', email: 'grace@testspectra.dev' },
|
|
39
39
|
];
|
|
40
40
|
const API_TODOS = [{ userId: 1, id: 1, title: 'Verify the self-hosted demo API responds', completed: false }];
|
|
41
|
+
/** Fixed demo credentials — this is a deterministic offline sandbox, not a real auth system. */
|
|
42
|
+
const DEMO_ACCOUNT = { email: 'demo@testspectra.dev', password: 'demo-password' };
|
|
43
|
+
/**
|
|
44
|
+
* Issued session tokens, in-memory only (reset on server restart) — backs both `/api/login`'s
|
|
45
|
+
* `Set-Cookie` (web, read via `Spectra.browser.setCookies()`) and its JSON `token` field
|
|
46
|
+
* (Android, read by the app's own deep-link-triggered login and stored via its own secure
|
|
47
|
+
* storage — see docs/v2/features/authentication-and-session-seeding.md). `/api/me` looks a
|
|
48
|
+
* caller's cookie *or* `Authorization: Bearer <token>` up in this same map.
|
|
49
|
+
*/
|
|
50
|
+
const issuedSessions = new Map();
|
|
41
51
|
/**
|
|
42
52
|
* Standalone self-hosted REST API server for the demo apps, kept separate from `DemoServer` (the
|
|
43
53
|
* static web app) so the API can run independently and either server can be swapped for a real
|
|
@@ -72,7 +82,12 @@ export class DemoApiServer {
|
|
|
72
82
|
});
|
|
73
83
|
}
|
|
74
84
|
handleApi(req, res, pathname) {
|
|
75
|
-
|
|
85
|
+
// The demo web app (port 5173) calls this server (port 5200) cross-origin. A wildcard origin
|
|
86
|
+
// can't be combined with credentialed requests (Access-Control-Allow-Credentials), which
|
|
87
|
+
// /api/me below needs to receive the session cookie seeded via Spectra.browser.setCookies() —
|
|
88
|
+
// reflect the actual request Origin instead so both credentialed and simple requests work.
|
|
89
|
+
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
|
|
90
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
76
91
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
77
92
|
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
78
93
|
if (req.method === 'OPTIONS') {
|
|
@@ -87,6 +102,36 @@ export class DemoApiServer {
|
|
|
87
102
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
88
103
|
res.end(JSON.stringify(payload));
|
|
89
104
|
};
|
|
105
|
+
if (pathname === '/api/login' && req.method === 'POST') {
|
|
106
|
+
const chunks = [];
|
|
107
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
108
|
+
req.on('end', () => {
|
|
109
|
+
let payload = {};
|
|
110
|
+
try {
|
|
111
|
+
payload = JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}');
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
if (payload.email !== DEMO_ACCOUNT.email || payload.password !== DEMO_ACCOUNT.password) {
|
|
115
|
+
sendJson(401, { error: 'Invalid credentials' });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const token = `demo-session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
119
|
+
issuedSessions.set(token, { email: DEMO_ACCOUNT.email });
|
|
120
|
+
// HttpOnly: JS on the page can't read this — only the browser's own network layer (and
|
|
121
|
+
// Spectra.browser.setCookies(), which operates at that same layer) can set/send it.
|
|
122
|
+
res.setHeader('Set-Cookie', `demo_session=${token}; Path=/; HttpOnly; SameSite=Lax`);
|
|
123
|
+
sendJson(200, { token, user: { email: DEMO_ACCOUNT.email } });
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (pathname === '/api/me' && req.method === 'GET') {
|
|
128
|
+
const cookieHeader = req.headers.cookie || '';
|
|
129
|
+
const cookieToken = /(?:^|;\s*)demo_session=([^;]+)/.exec(cookieHeader)?.[1];
|
|
130
|
+
const bearerToken = /^Bearer\s+(.+)$/.exec(req.headers.authorization || '')?.[1];
|
|
131
|
+
const session = issuedSessions.get(cookieToken || '') || issuedSessions.get(bearerToken || '');
|
|
132
|
+
sendJson(200, session ? { authenticated: true, user: session } : { authenticated: false });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
90
135
|
if (pathname === '/api/posts' && req.method === 'GET') {
|
|
91
136
|
sendJson(200, API_POSTS);
|
|
92
137
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testspectra/cli",
|
|
3
|
-
"version": "1.1.0-rc.
|
|
3
|
+
"version": "1.1.0-rc.1",
|
|
4
4
|
"description": "TestSpectra Zero-Config Cross-Platform Test Runner CLI",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -35,8 +35,9 @@
|
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@clack/prompts": "catalog:",
|
|
38
|
-
"@testspectra/matchers": "^1.1.0-rc.
|
|
39
|
-
"@testspectra/
|
|
38
|
+
"@testspectra/matchers": "^1.1.0-rc.1",
|
|
39
|
+
"@testspectra/react": "workspace:*",
|
|
40
|
+
"@testspectra/skills": "^1.1.0-rc.1",
|
|
40
41
|
"chalk": "catalog:",
|
|
41
42
|
"commander": "catalog:",
|
|
42
43
|
"dotenv": "catalog:",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"postinstall": "spectra sync-types"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@testspectra/cli": "^1.1.0-rc.
|
|
21
|
-
"@testspectra/matchers": "^1.1.0-rc.
|
|
20
|
+
"@testspectra/cli": "^1.1.0-rc.1",
|
|
21
|
+
"@testspectra/matchers": "^1.1.0-rc.1",
|
|
22
22
|
"@types/node": "^20.14.0",
|
|
23
23
|
"@wdio/cli": "^9.2.8",
|
|
24
24
|
"@wdio/local-runner": "^9.2.8",
|
|
@@ -20,6 +20,11 @@ class MatchersPage {
|
|
|
20
20
|
get focusInput() { return Spectra.get("~focus-input"); }
|
|
21
21
|
get productCards() { return Spectra.getAll("~product-item-1, ~product-item-2, ~product-item-3, ~product-item-4"); }
|
|
22
22
|
get emptyList() { return Spectra.getAll("~empty-list-item"); }
|
|
23
|
+
get overviewSection() { return Spectra.get("#overview-section"); }
|
|
24
|
+
// Scoped lookups via element.get() — resolved within overview-section's own bounds (Android
|
|
25
|
+
// has no ancestor API, so this is a bounds-containment lookup), not screen-wide.
|
|
26
|
+
overviewSubtitle() { return this.overviewSection.get("#demo-page-subtitle"); }
|
|
27
|
+
overviewOutsideElement() { return this.overviewSection.get("~submit-button"); }
|
|
23
28
|
|
|
24
29
|
// API Demo elements
|
|
25
30
|
get fetchApiButton() { return Spectra.get("~fetch-api-btn"); }
|
|
@@ -46,6 +51,14 @@ class MatchersPage {
|
|
|
46
51
|
const isDrawerOpen = await Spectra.get("~close-sidebar-btn").isDisplayed().catch(() => false);
|
|
47
52
|
if (!isDrawerOpen) {
|
|
48
53
|
await Spectra.get("~toggle-sidebar-btn").click();
|
|
54
|
+
// The mobile drawer is a React Native `<Modal>` — a separate OS-level window with its own
|
|
55
|
+
// slide-in animation, not a plain re-rendered View — so its content (including every
|
|
56
|
+
// nav-item below) isn't in the accessibility tree at all until Android actually finishes
|
|
57
|
+
// creating that window, which is async and occasionally slow under load. Without this
|
|
58
|
+
// wait, clicking a nav item right after the toggle relies entirely on click()'s own
|
|
59
|
+
// poll-retry to bridge that gap, which can eat most of the ~10s implicit-wait budget when
|
|
60
|
+
// the Modal is slow to materialize.
|
|
61
|
+
await Spectra.get("~close-sidebar-btn").waitForElement(10000);
|
|
49
62
|
}
|
|
50
63
|
if (section) {
|
|
51
64
|
await Spectra.get(`~nav-item-${section}`).click();
|
|
@@ -16,6 +16,11 @@ class MatchersPage {
|
|
|
16
16
|
get focusInput() { return Spectra.get("#demo-focus-input"); }
|
|
17
17
|
get productCards() { return Spectra.getAll(".demo-product-card"); }
|
|
18
18
|
get emptyList() { return Spectra.getAll("#demo-empty-list > *"); }
|
|
19
|
+
get overviewSection() { return Spectra.get("#overview"); }
|
|
20
|
+
// Scoped lookups via element.get() — resolved within overview-section's own subtree, not
|
|
21
|
+
// document-wide.
|
|
22
|
+
overviewSubtitle() { return this.overviewSection.get("#demo-page-subtitle"); }
|
|
23
|
+
overviewOutsideElement() { return this.overviewSection.get("#demo-submit-btn"); }
|
|
19
24
|
|
|
20
25
|
// API Interception & Mocking elements
|
|
21
26
|
get fetchApiButton() { return Spectra.get("#fetch-api-btn"); }
|
|
@@ -20,9 +20,15 @@ class PlaygroundPage {
|
|
|
20
20
|
|
|
21
21
|
async open() {
|
|
22
22
|
try {
|
|
23
|
+
// See MatchersPage.open()'s equivalent comment: waits for the cold-start home screen, then
|
|
24
|
+
// for the drawer Modal to actually finish materializing before clicking a nav item inside
|
|
25
|
+
// it — both races that otherwise surface as an occasional ~10s stall on the next click.
|
|
26
|
+
await Spectra.get("~toggle-sidebar-btn").waitForElement(30000);
|
|
27
|
+
|
|
23
28
|
const isDrawerOpen = await Spectra.get("~close-sidebar-btn").isDisplayed().catch(() => false);
|
|
24
29
|
if (!isDrawerOpen) {
|
|
25
30
|
await Spectra.get("~toggle-sidebar-btn").click();
|
|
31
|
+
await Spectra.get("~close-sidebar-btn").waitForElement(10000);
|
|
26
32
|
}
|
|
27
33
|
await Spectra.get("~nav-item-playground").click();
|
|
28
34
|
} catch {}
|
package/templates/default/specs/ApiInterception/TC-0012-direct-and-late-response/common.test.ts
CHANGED
|
@@ -19,15 +19,12 @@ it("Intercept a direct (immediate) response and a late (delayed) response using
|
|
|
19
19
|
// Late (delayed) response — same mock rule, switched to hold the response back via `delayMs`.
|
|
20
20
|
// The loading indicator must still be visible right after the click, proving this is real
|
|
21
21
|
// network-level latency (the request genuinely stays pending) rather than a client-side-only
|
|
22
|
-
// spinner timer. The
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
// arrived and rendered — this doesn't also assert the indicator later disappears via
|
|
27
|
-
// `shouldNotExist`, for the same 2s-cap margin reason.
|
|
28
|
-
await mock.respondWith(Fixture.apiData.mockPosts, { delayMs: 1000 });
|
|
22
|
+
// spinner timer. The delay is set past the default assertion timeout on purpose, to demonstrate
|
|
23
|
+
// the per-assertion `{ timeoutMs }` override below — without it, these assertions would fail-fast
|
|
24
|
+
// before the delayed response ever arrives.
|
|
25
|
+
await mock.respondWith(Fixture.apiData.mockPosts, { delayMs: 2500 });
|
|
29
26
|
await MatchersPage.fetchApiButton.click();
|
|
30
27
|
await MatchersPage.fetchLoadingIndicator.shouldBeVisible();
|
|
31
|
-
await MatchersPage.fetchStatusBadge.shouldHaveText("200 OK");
|
|
32
|
-
await MatchersPage.fetchedItems.shouldHaveLength(Fixture.apiData.mockPosts.length);
|
|
28
|
+
await MatchersPage.fetchStatusBadge.shouldHaveText("200 OK", { timeoutMs: 3500 });
|
|
29
|
+
await MatchersPage.fetchedItems.shouldHaveLength(Fixture.apiData.mockPosts.length, { timeoutMs: 3500 });
|
|
33
30
|
});
|
package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
it("Seed an authenticated session via Spectra.seedSession() instead of the real login UI", async () => {
|
|
2
|
+
// The deep link asks the app to log itself in against a known test account and persist the
|
|
3
|
+
// result itself — no login form was ever touched.
|
|
4
|
+
await Spectra.seedSession();
|
|
5
|
+
|
|
6
|
+
await AuthPage.statusBadge.shouldHaveText("Authenticated as demo@testspectra.dev");
|
|
7
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: TC-0021-seed-authenticated-session
|
|
3
|
+
title: Seed an authenticated session via Spectra.seedSession() instead of the real login UI
|
|
4
|
+
priority: High
|
|
5
|
+
caseType: Positive
|
|
6
|
+
status: ready-for-automation
|
|
7
|
+
coverage:
|
|
8
|
+
web: automated
|
|
9
|
+
mobile: automated
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Seed an authenticated session via Spectra.seedSession() instead of the real login UI
|
|
13
|
+
|
|
14
|
+
## Test Steps
|
|
15
|
+
1. Call the `seedSession` support action — logs in via a direct API call instead of walking
|
|
16
|
+
through a login form (web: host-side `fetch()` + `Spectra.browser.setCookies()` applies the
|
|
17
|
+
resulting `HttpOnly` cookie to the browser; mobile: a deep link asks the app to log itself in
|
|
18
|
+
against a known test account and persist the result itself)
|
|
19
|
+
2. Assert the app reflects an authenticated state
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
it("Seed an authenticated session via Spectra.seedSession() instead of the real login UI", async () => {
|
|
2
|
+
// Logs in via a direct host-side API call and applies the resulting HttpOnly cookie straight
|
|
3
|
+
// to the browser — no login form was ever touched.
|
|
4
|
+
await Spectra.seedSession();
|
|
5
|
+
|
|
6
|
+
await AuthPage.open();
|
|
7
|
+
await AuthPage.checkStatusButton.click();
|
|
8
|
+
await AuthPage.statusBadge.shouldHaveText("Authenticated as demo@testspectra.dev");
|
|
9
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
it("Navigate browser history with Spectra.back(), forward(), and refresh()", async () => {
|
|
2
|
+
// Same cold-start race as DeepLink/TC-0018 — wait for the home screen before firing the deep
|
|
3
|
+
// link so it isn't racing the app's own still-in-progress initial boot.
|
|
4
|
+
await Spectra.get("~toggle-sidebar-btn").waitForElement(30000);
|
|
5
|
+
|
|
6
|
+
await Spectra.navigate("testspectra-demo://deeplink-target");
|
|
7
|
+
await Spectra.get("~deeplink-target-marker").waitForElement(10000);
|
|
8
|
+
|
|
9
|
+
// Spectra.back() on Android presses the hardware back key — distinct from tapping the screen's
|
|
10
|
+
// own "‹ Back" button (already covered by DeepLink/TC-0018) — and should pop the nav stack back
|
|
11
|
+
// to Home exactly the same way.
|
|
12
|
+
await Spectra.back();
|
|
13
|
+
await Spectra.get("~toggle-sidebar-btn").waitForElement(10000);
|
|
14
|
+
|
|
15
|
+
// Spectra.forward()/Spectra.refresh() have no native-app equivalent (no browser history, no
|
|
16
|
+
// page reload) — they're intentional no-ops on Android, so there's nothing to assert here.
|
|
17
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: TC-0022-history-navigation
|
|
3
|
+
title: Navigate browser/app history with Spectra.back(), forward(), and refresh()
|
|
4
|
+
priority: Medium
|
|
5
|
+
caseType: Positive
|
|
6
|
+
status: ready-for-automation
|
|
7
|
+
coverage:
|
|
8
|
+
web: automated
|
|
9
|
+
mobile: automated
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Navigate browser/app history with Spectra.back(), forward(), and refresh()
|
|
13
|
+
|
|
14
|
+
## Test Steps
|
|
15
|
+
|
|
16
|
+
### Web
|
|
17
|
+
1. Navigate to two distinct real URLs in sequence, each pushing its own genuine browser history
|
|
18
|
+
entry (this demo app's in-app hash navigation does not — see the test's own comment)
|
|
19
|
+
2. Verify `Spectra.back()` returns to the previously visited URL
|
|
20
|
+
3. Verify `Spectra.forward()` replays the entry just undone
|
|
21
|
+
4. Verify `Spectra.refresh()` reloads the document while the current URL survives
|
|
22
|
+
|
|
23
|
+
### Mobile
|
|
24
|
+
1. Navigate to a native route via deep link
|
|
25
|
+
2. Verify `Spectra.back()` (Android hardware back key) pops back to the Home screen
|
|
26
|
+
|
|
27
|
+
`Spectra.forward()`/`Spectra.refresh()` have no native-app equivalent (no browser history, no page
|
|
28
|
+
reload) and are intentional no-ops on Android — only `Spectra.back()` is exercised on mobile.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
it("Navigate browser history with Spectra.back(), forward(), and refresh()", async () => {
|
|
2
|
+
await MatchersPage.open();
|
|
3
|
+
|
|
4
|
+
// This demo app is single-page — its in-app "navigation" (Spectra.navigate("/#section")) only
|
|
5
|
+
// ever sets window.location.hash, which this browser does not push as a traversable history
|
|
6
|
+
// entry (history.length never grows), so there is nothing for back()/forward() to jump between.
|
|
7
|
+
// Two distinct real paths give them something real: Vite's dev server serves the same SPA shell
|
|
8
|
+
// for any path, but the browser still treats each as its own document and pushes a genuine
|
|
9
|
+
// history entry — exactly like a real multi-page site would.
|
|
10
|
+
await Spectra.navigate("/history-nav-a");
|
|
11
|
+
await Spectra.browser.shouldContainUrl("/history-nav-a");
|
|
12
|
+
|
|
13
|
+
await Spectra.navigate("/history-nav-b");
|
|
14
|
+
await Spectra.browser.shouldContainUrl("/history-nav-b");
|
|
15
|
+
|
|
16
|
+
await Spectra.back();
|
|
17
|
+
await Spectra.browser.shouldContainUrl("/history-nav-a");
|
|
18
|
+
|
|
19
|
+
await Spectra.forward();
|
|
20
|
+
await Spectra.browser.shouldContainUrl("/history-nav-b");
|
|
21
|
+
|
|
22
|
+
// The document reloads, but the current URL survives it.
|
|
23
|
+
await Spectra.refresh();
|
|
24
|
+
await Spectra.browser.shouldBeLoaded();
|
|
25
|
+
await Spectra.browser.shouldContainUrl("/history-nav-b");
|
|
26
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
it("element.get()/getAll() resolves a child scoped to its parent's own subtree", async () => {
|
|
2
|
+
await MatchersPage.open();
|
|
3
|
+
await MatchersPage.overviewSection.shouldBeVisible();
|
|
4
|
+
|
|
5
|
+
// Positive: the subtitle is a real descendant of the overview section, so the scoped lookup
|
|
6
|
+
// resolves it.
|
|
7
|
+
await MatchersPage.overviewSubtitle().shouldExist();
|
|
8
|
+
|
|
9
|
+
// Negative (the actual scoping proof): the submit button lives in a different section
|
|
10
|
+
// entirely. If .get() weren't genuinely scoped (e.g. silently fell back to a page-wide
|
|
11
|
+
// lookup), this would incorrectly resolve to the globally-existing element instead of
|
|
12
|
+
// correctly not finding it.
|
|
13
|
+
await MatchersPage.overviewOutsideElement().shouldNotExist();
|
|
14
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: TC-0020-scoped-element-access
|
|
3
|
+
title: element.get()/getAll() resolves a child scoped to its parent's own subtree
|
|
4
|
+
priority: High
|
|
5
|
+
caseType: Positive
|
|
6
|
+
status: ready-for-automation
|
|
7
|
+
coverage:
|
|
8
|
+
web: automated
|
|
9
|
+
mobile: automated
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# element.get()/getAll() resolves a child scoped to its parent's own subtree
|
|
13
|
+
|
|
14
|
+
## Test Steps
|
|
15
|
+
1. Open the matchers page and assert the overview section is visible
|
|
16
|
+
2. From the overview section, resolve the page subtitle via `.get()` and assert it exists — the
|
|
17
|
+
subtitle is a real descendant, so the scoped lookup finds it
|
|
18
|
+
3. From the same overview section, resolve the submit button via `.get()` and assert it does NOT
|
|
19
|
+
exist — the submit button lives in a different section entirely, so this only passes if the
|
|
20
|
+
lookup is genuinely scoped to overview's own subtree rather than silently falling back to a
|
|
21
|
+
page-wide match
|
package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: TC-0019-read-custom-environment-variable
|
|
3
|
+
title: Verify a custom environmentVariables entry is readable via Spectra.env
|
|
4
|
+
priority: Medium
|
|
5
|
+
caseType: Positive
|
|
6
|
+
status: ready-for-automation
|
|
7
|
+
coverage:
|
|
8
|
+
web: automated
|
|
9
|
+
mobile: automated
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Verify a custom environmentVariables entry is readable via Spectra.env
|
|
13
|
+
|
|
14
|
+
## Test Steps
|
|
15
|
+
1. Read the DEMO_API_MODE variable via the typed Spectra.env
|
|
16
|
+
2. Assert it matches the value configured in spectra.config.ts's executionConfig.environmentVariables
|
|
@@ -6,17 +6,8 @@ export default defineConfig({
|
|
|
6
6
|
maxConcurrentSessions: "1",
|
|
7
7
|
headless: true,
|
|
8
8
|
implicitWait: "5000",
|
|
9
|
-
pageLoadTimeout: "30000",
|
|
10
|
-
scriptTimeout: "30000",
|
|
11
9
|
parallelizationMode: "testcase",
|
|
12
10
|
},
|
|
13
|
-
browsers: [
|
|
14
|
-
{
|
|
15
|
-
id: "chrome-desktop",
|
|
16
|
-
type: "chrome",
|
|
17
|
-
mobileEmulation: false,
|
|
18
|
-
},
|
|
19
|
-
],
|
|
20
11
|
androidConfig: {
|
|
21
12
|
driverServer: "tcp://127.0.0.1:8200",
|
|
22
13
|
appPackage: "dev.testspectra.demoapp",
|
|
@@ -51,17 +42,11 @@ export default defineConfig({
|
|
|
51
42
|
},
|
|
52
43
|
],
|
|
53
44
|
},
|
|
54
|
-
loadConfig: {
|
|
55
|
-
virtualUsers: "10",
|
|
56
|
-
duration: "1m",
|
|
57
|
-
},
|
|
58
|
-
loadStages: [],
|
|
59
|
-
thresholds: [],
|
|
60
45
|
executionConfig: {
|
|
61
46
|
networkMonitoringEnabled: true,
|
|
62
47
|
fastResponseTime: "200",
|
|
63
48
|
normalResponseTime: "1000",
|
|
64
49
|
monitoredDomains: [],
|
|
65
|
-
environmentVariables:
|
|
50
|
+
environmentVariables: { DEMO_API_MODE: "sandbox" },
|
|
66
51
|
},
|
|
67
52
|
});
|