@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.
Files changed (105) hide show
  1. package/bin/.testspectra-runner.hash +1 -0
  2. package/bin/testspectra-runner +0 -0
  3. package/demo-app/assets/index-DOtRypCa.js +154 -0
  4. package/demo-app/index.html +1 -1
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/commands/__tests__/run.test.js +9 -13
  7. package/dist/commands/init.d.ts +1 -1
  8. package/dist/commands/init.js +6 -1
  9. package/dist/commands/run.js +1 -0
  10. package/dist/commands/sync-types.js +1 -0
  11. package/dist/commands/test.d.ts +17 -0
  12. package/dist/commands/test.js +76 -0
  13. package/dist/commands/watch.js +9 -2
  14. package/dist/config/loader.js +1 -1
  15. package/dist/config/schema.d.ts +5 -109
  16. package/dist/config/schema.js +1 -16
  17. package/dist/generator/__tests__/tsconfig-isolation.test.js +6 -2
  18. package/dist/generator/tsconfig-generator.js +42 -7
  19. package/dist/generator/type-generator.d.ts +24 -1
  20. package/dist/generator/type-generator.js +104 -8
  21. package/dist/index.js +15 -1
  22. package/dist/linter/__tests__/component-test-imports.test.d.ts +1 -0
  23. package/dist/linter/__tests__/component-test-imports.test.js +115 -0
  24. package/dist/linter/extractor.d.ts +1 -0
  25. package/dist/linter/extractor.js +11 -1
  26. package/dist/linter/schemas/spec.schema.d.ts +12 -12
  27. package/dist/linter/schemas/suite.schema.d.ts +8 -8
  28. package/dist/linter/spec-linter.js +38 -3
  29. package/dist/reporter/semantic-formatter.js +17 -2
  30. package/dist/reporter/types.d.ts +1 -1
  31. package/dist/runner/reporter.js +7 -3
  32. package/dist/utils/api-server.js +46 -1
  33. package/package.json +4 -3
  34. package/templates/default/package.json +2 -2
  35. package/templates/default/page-objects/AuthPage/mobile.ts +5 -0
  36. package/templates/default/page-objects/AuthPage/web.ts +10 -0
  37. package/templates/default/page-objects/MatchersPage/mobile.ts +13 -0
  38. package/templates/default/page-objects/MatchersPage/web.ts +5 -0
  39. package/templates/default/page-objects/PlaygroundPage/mobile.ts +6 -0
  40. package/templates/default/specs/ApiInterception/TC-0012-direct-and-late-response/common.test.ts +6 -9
  41. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +7 -0
  42. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/spec.md +19 -0
  43. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/web.test.ts +9 -0
  44. package/templates/default/specs/Authentication/suite.md +9 -0
  45. package/templates/default/specs/BrowserContext/TC-0022-history-navigation/mobile.test.ts +17 -0
  46. package/templates/default/specs/BrowserContext/TC-0022-history-navigation/spec.md +28 -0
  47. package/templates/default/specs/BrowserContext/TC-0022-history-navigation/web.test.ts +26 -0
  48. package/templates/default/specs/ContentMatchers/TC-0020-scoped-element-access/common.test.ts +14 -0
  49. package/templates/default/specs/ContentMatchers/TC-0020-scoped-element-access/spec.md +21 -0
  50. package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/common.test.ts +5 -0
  51. package/templates/default/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md +16 -0
  52. package/templates/default/specs/EnvironmentConfig/suite.md +9 -0
  53. package/templates/default/spectra.config.ts +1 -16
  54. package/templates/default/support/actions/seedSession/mobile.action.ts +9 -0
  55. package/templates/default/support/actions/seedSession/web.action.ts +13 -0
  56. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-shm +0 -0
  57. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-wal +0 -0
  58. package/templates/nx/.nx/workspace-data/d/daemon.log +1060 -0
  59. package/templates/nx/.nx/workspace-data/d/server-process.json +1 -1
  60. package/templates/nx/.nx/workspace-data/file-map.json +116 -104
  61. package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
  62. package/templates/nx/package.json +2 -2
  63. package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/common.test.ts +5 -0
  64. package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/TC-0019-read-custom-environment-variable/spec.md +16 -0
  65. package/templates/nx/packages/matchers/e2e/specs/EnvironmentConfig/suite.md +9 -0
  66. package/templates/nx/spectra.config.ts +1 -16
  67. package/templates/react-component/.cursorrules +17 -0
  68. package/templates/react-component/.vscode/extensions.json +5 -0
  69. package/templates/react-component/CLAUDE.md +14 -0
  70. package/templates/react-component/README.md +69 -0
  71. package/templates/react-component/fixtures/component-mock.json +12 -0
  72. package/templates/react-component/global-hooks/after/web.hook.ts +3 -0
  73. package/templates/react-component/global-hooks/before/web.hook.ts +3 -0
  74. package/templates/react-component/package.json +27 -0
  75. package/templates/react-component/page-objects/BadgeComponent/web.ts +23 -0
  76. package/templates/react-component/page-objects/ButtonComponent/web.ts +23 -0
  77. package/templates/react-component/page-objects/InputComponent/web.ts +13 -0
  78. package/templates/react-component/specs/BadgeComponent/TC-0002-badge-severities-and-dot/spec.md +18 -0
  79. package/templates/react-component/specs/BadgeComponent/TC-0002-badge-severities-and-dot/web.test.tsx +11 -0
  80. package/templates/react-component/specs/BadgeComponent/TC-0006-badge-success-with-dot/spec.md +18 -0
  81. package/templates/react-component/specs/BadgeComponent/TC-0006-badge-success-with-dot/web.test.tsx +10 -0
  82. package/templates/react-component/specs/BadgeComponent/TC-0007-badge-by-severity-lookup/spec.md +18 -0
  83. package/templates/react-component/specs/BadgeComponent/TC-0007-badge-by-severity-lookup/web.test.tsx +16 -0
  84. package/templates/react-component/specs/BadgeComponent/suite.md +12 -0
  85. package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/spec.md +19 -0
  86. package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/web.test.tsx +28 -0
  87. package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/spec.md +19 -0
  88. package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/web.test.tsx +22 -0
  89. package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/spec.md +18 -0
  90. package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/web.test.tsx +8 -0
  91. package/templates/react-component/specs/ButtonComponent/suite.md +12 -0
  92. package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/spec.md +20 -0
  93. package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/web.test.tsx +28 -0
  94. package/templates/react-component/specs/InputComponent/TC-0008-disabled-input-not-editable/spec.md +18 -0
  95. package/templates/react-component/specs/InputComponent/TC-0008-disabled-input-not-editable/web.test.tsx +9 -0
  96. package/templates/react-component/specs/InputComponent/suite.md +12 -0
  97. package/templates/react-component/spectra.config.ts +27 -0
  98. package/templates/react-component/src/components/Badge/Badge.tsx +60 -0
  99. package/templates/react-component/src/components/Button/Button.tsx +57 -0
  100. package/templates/react-component/src/components/Input/Input.tsx +41 -0
  101. package/templates/react-component/src/test-utils.tsx +23 -0
  102. package/templates/react-component/support/actions/fillInputField/web.action.ts +9 -0
  103. package/templates/react-component/support/steps/verifyButtonState/web.step.ts +12 -0
  104. package/templates/react-component/tsconfig.json +8 -0
  105. 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('.common.ts')))
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
- // testspectra/no-manual-import: Manual import statement detected (must be zero-import)
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: 'Manual imports forbidden; all TestSpectra APIs, Page Objects, Steps, and Fixtures are ambient globals',
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
- return `element "${sel}"`;
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
- return `collection "${sel}"`;
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:
@@ -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';
@@ -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
- const target = typeof payload.target === 'object' && payload.target !== null
67
- ? payload.target.selector || payload.target.name || payload.target.target || payload.target
68
- : payload.target;
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
  }
@@ -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
- res.setHeader('Access-Control-Allow-Origin', '*');
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.0",
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.0",
39
- "@testspectra/skills": "^1.1.0-rc.0",
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.0",
21
- "@testspectra/matchers": "^1.1.0-rc.0",
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",
@@ -0,0 +1,5 @@
1
+ class AuthPage {
2
+ get statusBadge() { return Spectra.get("~auth-status-badge"); }
3
+ }
4
+
5
+ export default new AuthPage();
@@ -0,0 +1,10 @@
1
+ class AuthPage {
2
+ get checkStatusButton() { return Spectra.get("#check-auth-status-btn"); }
3
+ get statusBadge() { return Spectra.get("#auth-status-badge"); }
4
+
5
+ async open() {
6
+ await Spectra.navigate("/#auth");
7
+ }
8
+ }
9
+
10
+ export default new AuthPage();
@@ -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 {}
@@ -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 indicator is a non-accessible transient whose a11y entry lags the render,
23
- // so a short hold raced the (now much faster) action poll; a full second makes it reliably
24
- // observable while the delayed response still renders within the hard-capped 2s assertion budget
25
- // the next assertions run under. The eventual correct status/content proves the delayed response
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
  });
@@ -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,9 @@
1
+ ---
2
+ id: suite-authentication
3
+ name: Authentication
4
+ description: Skipping the real login UI by seeding an authenticated session via a direct API call
5
+ executionOrder: 2.6
6
+ parallel: true
7
+ ---
8
+
9
+ # Suite: Authentication
@@ -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
@@ -0,0 +1,5 @@
1
+ it("Verify a custom environmentVariables entry is readable via Spectra.env", async () => {
2
+ if (Spectra.env.DEMO_API_MODE !== "sandbox") {
3
+ throw new Error(`Expected Spectra.env.DEMO_API_MODE to be "sandbox", got "${Spectra.env.DEMO_API_MODE}"`);
4
+ }
5
+ });
@@ -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
@@ -0,0 +1,9 @@
1
+ ---
2
+ id: suite-environment-config
3
+ name: EnvironmentConfig
4
+ description: Reading custom executionConfig.environmentVariables values via Spectra.env
5
+ executionOrder: 2.3
6
+ parallel: true
7
+ ---
8
+
9
+ # Suite: EnvironmentConfig
@@ -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
  });