@testspectra/cli 1.1.11-rc.5 → 1.1.12

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 (89) hide show
  1. package/demo-app/assets/index-DDYS5fLp.css +1 -0
  2. package/demo-app/assets/index-euCENPMy.js +154 -0
  3. package/demo-app/index.html +2 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/commands/__tests__/run-e2e.test.js +24 -6
  6. package/dist/commands/demo.d.ts +1 -0
  7. package/dist/commands/demo.js +26 -4
  8. package/dist/commands/doctor.js +23 -0
  9. package/dist/commands/run.d.ts +3 -0
  10. package/dist/commands/run.js +41 -4
  11. package/dist/commands/test.d.ts +9 -9
  12. package/dist/commands/test.js +40 -75
  13. package/dist/commands/update.d.ts +3 -0
  14. package/dist/commands/update.js +109 -0
  15. package/dist/config/loader.js +31 -4
  16. package/dist/config/schema.d.ts +58 -0
  17. package/dist/config/schema.js +4 -1
  18. package/dist/generator/__tests__/tsconfig-isolation.test.js +21 -0
  19. package/dist/generator/__tests__/type-generator.test.js +14 -0
  20. package/dist/generator/tsconfig-generator.js +1 -0
  21. package/dist/generator/type-generator.js +2 -0
  22. package/dist/index.js +8 -4
  23. package/dist/lifecycle/__tests__/lifecycle-engine.test.js +290 -0
  24. package/dist/lifecycle/hook-discovery.d.ts +24 -0
  25. package/dist/lifecycle/hook-discovery.js +60 -0
  26. package/dist/lifecycle/hook-dispatcher.d.ts +34 -0
  27. package/dist/lifecycle/hook-dispatcher.js +190 -0
  28. package/dist/lifecycle/index.d.ts +3 -0
  29. package/dist/lifecycle/index.js +3 -0
  30. package/dist/lifecycle/parallel-grouping.d.ts +27 -0
  31. package/dist/lifecycle/parallel-grouping.js +151 -0
  32. package/dist/reporter/semantic-formatter.js +2 -0
  33. package/dist/reporter/types.d.ts +1 -1
  34. package/dist/runner/bridge.d.ts +2 -0
  35. package/dist/runner/bridge.js +4 -1
  36. package/dist/runner/reporter.d.ts +5 -43
  37. package/dist/runner/reporter.js +46 -179
  38. package/dist/utils/__tests__/demo-state.test.d.ts +1 -0
  39. package/dist/utils/__tests__/demo-state.test.js +96 -0
  40. package/dist/utils/api-server.d.ts +1 -1
  41. package/dist/utils/api-server.js +7 -3
  42. package/dist/utils/demo-server.d.ts +2 -0
  43. package/dist/utils/demo-server.js +18 -5
  44. package/dist/utils/demo-state.d.ts +40 -0
  45. package/dist/utils/demo-state.js +157 -0
  46. package/package.json +9 -8
  47. package/templates/default/fixtures/sampleDocument.pdf +14 -0
  48. package/templates/default/fixtures/sampleImage.png +0 -0
  49. package/templates/default/package.json +2 -2
  50. package/templates/default/page-objects/MatchersPage/common.ts +41 -0
  51. package/templates/default/page-objects/MatchersPage/mobile.ts +8 -3
  52. package/templates/default/page-objects/PlaygroundPage/mobile.ts +6 -0
  53. package/templates/default/page-objects/PlaygroundPage/web.ts +2 -0
  54. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +3 -0
  55. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +3 -0
  56. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  57. package/templates/default/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  58. package/templates/default/spectra.config.ts +5 -1
  59. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db +0 -0
  60. package/templates/nx/.nx/workspace-data/d/daemon.log +7063 -1012
  61. package/templates/nx/.nx/workspace-data/file-map.json +181 -169
  62. package/templates/nx/.nx/workspace-data/lockfile.hash +1 -1
  63. package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
  64. package/templates/nx/.nx/workspace-data/parsed-lock-file.json +81 -130
  65. package/templates/nx/.nx/workspace-data/project-graph.json +83 -138
  66. package/templates/nx/package.json +2 -2
  67. package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  68. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  69. package/templates/nx/shared/testing/fixtures/sampleDocument.pdf +14 -0
  70. package/templates/nx/shared/testing/fixtures/sampleImage.png +0 -0
  71. package/templates/nx/shared/testing/page-objects/MatchersPage/mobile.ts +68 -26
  72. package/templates/nx/shared/testing/page-objects/PlaygroundPage/mobile.ts +39 -17
  73. package/templates/nx/shared/testing/page-objects/PlaygroundPage/web.ts +2 -0
  74. package/templates/nx/spectra.config.ts +3 -3
  75. package/templates/react-component/fixtures/sampleDocument.pdf +14 -0
  76. package/templates/react-component/fixtures/sampleImage.png +0 -0
  77. package/templates/react-component/package.json +3 -3
  78. package/templates/react-component/page-objects/FileUploadComponent/web.ts +15 -0
  79. package/templates/react-component/specs/FileUploadComponent/TC-0001-upload-fixtures/web.test.tsx +18 -0
  80. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/spec.md +20 -0
  81. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/web.test.tsx +15 -0
  82. package/templates/react-component/specs/UserListComponent/suite.md +12 -0
  83. package/templates/react-component/src/components/FileUpload/FileUpload.tsx +44 -0
  84. package/templates/react-component/src/components/UserList/UserList.tsx +48 -0
  85. package/bin/.testspectra-runner.hash +0 -1
  86. package/demo-app/assets/index-BhlW-eXG.css +0 -1
  87. package/demo-app/assets/index-DOtRypCa.js +0 -154
  88. package/dist/commands/__tests__/doctor-scope.test.js +0 -24
  89. /package/dist/{commands/__tests__/doctor-scope.test.d.ts → lifecycle/__tests__/lifecycle-engine.test.d.ts} +0 -0
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import http from 'http';
6
+ import { DemoStateManager } from '../demo-state.js';
7
+ describe('DemoStateManager (cli/src/utils/demo-state.ts)', () => {
8
+ let tmpDir;
9
+ let testServer = null;
10
+ let testPort = 0;
11
+ beforeAll(async () => {
12
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-demo-state-test-'));
13
+ fs.mkdirSync(path.join(tmpDir, '.testspectra'), { recursive: true });
14
+ // Start a dummy HTTP server simulating a live demo web app
15
+ await new Promise((resolve) => {
16
+ testServer = http.createServer((_req, res) => {
17
+ res.writeHead(200, {
18
+ 'Content-Type': 'text/html',
19
+ 'X-TestSpectra-Demo': 'true',
20
+ });
21
+ res.end('<div id="app"></div>');
22
+ });
23
+ testServer.listen(0, '127.0.0.1', () => {
24
+ const addr = testServer?.address();
25
+ testPort = typeof addr === 'object' && addr !== null ? addr.port : 0;
26
+ resolve();
27
+ });
28
+ });
29
+ });
30
+ afterAll(async () => {
31
+ if (testServer) {
32
+ await new Promise((resolve) => testServer?.close(() => resolve()));
33
+ }
34
+ if (fs.existsSync(tmpDir)) {
35
+ fs.rmSync(tmpDir, { recursive: true, force: true });
36
+ }
37
+ });
38
+ it('saves and removes demo server state in the target project dir', () => {
39
+ const state = {
40
+ pid: process.pid,
41
+ url: `http://127.0.0.1:${testPort}`,
42
+ port: testPort,
43
+ apiUrl: 'http://127.0.0.1:5200',
44
+ apiPort: 5200,
45
+ startedAt: Date.now(),
46
+ };
47
+ DemoStateManager.saveState(state, tmpDir);
48
+ const stateFilePath = path.join(tmpDir, '.testspectra', 'demo-server.json');
49
+ expect(fs.existsSync(stateFilePath)).toBe(true);
50
+ const saved = JSON.parse(fs.readFileSync(stateFilePath, 'utf-8'));
51
+ expect(saved.url).toBe(`http://127.0.0.1:${testPort}`);
52
+ expect(saved.port).toBe(testPort);
53
+ DemoStateManager.removeState(tmpDir);
54
+ expect(fs.existsSync(stateFilePath)).toBe(false);
55
+ });
56
+ it('probeServer returns true for an active responsive HTTP server', async () => {
57
+ const isLive = await DemoStateManager.probeServer(`http://127.0.0.1:${testPort}`);
58
+ expect(isLive).toBe(true);
59
+ });
60
+ it('probeServer returns false for an inactive/closed port', async () => {
61
+ // 65534 is unlikely to be listening
62
+ const isLive = await DemoStateManager.probeServer('http://127.0.0.1:65534', 200);
63
+ expect(isLive).toBe(false);
64
+ });
65
+ it('getActiveDemoServer returns the state when the server is alive', async () => {
66
+ const state = {
67
+ pid: process.pid,
68
+ url: `http://127.0.0.1:${testPort}`,
69
+ port: testPort,
70
+ apiUrl: 'http://127.0.0.1:5200',
71
+ apiPort: 5200,
72
+ startedAt: Date.now(),
73
+ };
74
+ DemoStateManager.saveState(state, tmpDir);
75
+ const active = await DemoStateManager.getActiveDemoServer(tmpDir);
76
+ expect(active).not.toBeNull();
77
+ expect(active?.url).toBe(`http://127.0.0.1:${testPort}`);
78
+ expect(active?.port).toBe(testPort);
79
+ DemoStateManager.removeState(tmpDir);
80
+ });
81
+ it('getActiveDemoServer cleans up stale state file and returns null when server is not running', async () => {
82
+ const state = {
83
+ pid: 99999999, // Dead PID
84
+ url: 'http://127.0.0.1:65533', // Closed port
85
+ port: 65533,
86
+ startedAt: Date.now(),
87
+ };
88
+ DemoStateManager.saveState(state, tmpDir);
89
+ const stateFilePath = path.join(tmpDir, '.testspectra', 'demo-server.json');
90
+ expect(fs.existsSync(stateFilePath)).toBe(true);
91
+ const active = await DemoStateManager.getActiveDemoServer(tmpDir);
92
+ expect(active).toBeNull();
93
+ // Stale file should be cleaned up automatically
94
+ expect(fs.existsSync(stateFilePath)).toBe(false);
95
+ });
96
+ });
@@ -1,4 +1,4 @@
1
- /** Port the self-hosted demo API server listens on (separate from the demo web app's 5173). */
1
+ /** Port the self-hosted demo API server listens on (separate from the demo web app's 5180). */
2
2
  export declare const DEMO_API_PORT = 5200;
3
3
  /**
4
4
  * Standalone self-hosted REST API server for the demo apps, kept separate from `DemoServer` (the
@@ -1,5 +1,5 @@
1
1
  import http from 'http';
2
- /** Port the self-hosted demo API server listens on (separate from the demo web app's 5173). */
2
+ /** Port the self-hosted demo API server listens on (separate from the demo web app's 5180). */
3
3
  export const DEMO_API_PORT = 5200;
4
4
  // Self-hosted dummy REST data for the demo app's API sections — keeps the ApiInterception and
5
5
  // ApiSeeding suites fully offline/deterministic instead of depending on an external service
@@ -74,7 +74,11 @@ export class DemoApiServer {
74
74
  reject(err);
75
75
  }
76
76
  });
77
- this.server.listen(this.port, '0.0.0.0', () => {
77
+ // Bind dual-stack (no host) rather than IPv4-only `0.0.0.0`: an IPv4-only bind silently
78
+ // coexists with an IPv6-localhost squatter on the same port, so a conflict would go
79
+ // undetected and clients resolving `localhost` to `::1` would hit the foreign server.
80
+ // See DemoServer for the full explanation.
81
+ this.server.listen(this.port, () => {
78
82
  const addr = this.server?.address();
79
83
  const actualPort = typeof addr === 'object' && addr !== null ? addr.port : this.port;
80
84
  resolve({ url: `http://localhost:${actualPort}`, port: actualPort });
@@ -82,7 +86,7 @@ export class DemoApiServer {
82
86
  });
83
87
  }
84
88
  handleApi(req, res, pathname) {
85
- // The demo web app (port 5173) calls this server (port 5200) cross-origin. A wildcard origin
89
+ // The demo web app (port 5180) calls this server (port 5200) cross-origin. A wildcard origin
86
90
  // can't be combined with credentialed requests (Access-Control-Allow-Credentials), which
87
91
  // /api/me below needs to receive the session cookie seeded via Spectra.browser.setCookies() —
88
92
  // reflect the actual request Origin instead so both credentialed and simple requests work.
@@ -1,3 +1,5 @@
1
+ /** Default static port for the demo web app (distinct from Vite's default 5173 to avoid collisions). */
2
+ export declare const DEFAULT_DEMO_PORT = 5180;
1
3
  /**
2
4
  * Static file server for the demo web app bundle. Serves ONLY the web app — the self-hosted demo
3
5
  * REST API lives in a separate `DemoApiServer` (`cli/src/utils/api-server.ts`, port 5200) so the
@@ -16,6 +16,8 @@ const MIME_TYPES = {
16
16
  '.woff2': 'font/woff2',
17
17
  '.ttf': 'font/ttf',
18
18
  };
19
+ /** Default static port for the demo web app (distinct from Vite's default 5173 to avoid collisions). */
20
+ export const DEFAULT_DEMO_PORT = 5180;
19
21
  /**
20
22
  * Static file server for the demo web app bundle. Serves ONLY the web app — the self-hosted demo
21
23
  * REST API lives in a separate `DemoApiServer` (`cli/src/utils/api-server.ts`, port 5200) so the
@@ -24,7 +26,7 @@ const MIME_TYPES = {
24
26
  export class DemoServer {
25
27
  server = null;
26
28
  port;
27
- constructor(port = 5173) {
29
+ constructor(port = DEFAULT_DEMO_PORT) {
28
30
  this.port = port;
29
31
  }
30
32
  static resolveDemoDist() {
@@ -67,6 +69,7 @@ export class DemoServer {
67
69
  res.writeHead(200, {
68
70
  'Content-Type': contentType,
69
71
  'Access-Control-Allow-Origin': '*',
72
+ 'X-TestSpectra-Demo': 'true',
70
73
  });
71
74
  res.end(content);
72
75
  }
@@ -76,15 +79,25 @@ export class DemoServer {
76
79
  }
77
80
  });
78
81
  this.server.on('error', (err) => {
79
- if (err.code === 'EADDRINUSE') {
80
- // Port already in use (e.g. user already running server)
81
- resolve({ url: `http://localhost:${this.port}`, port: this.port });
82
+ if (err.code === 'EADDRINUSE' && this.port !== 0) {
83
+ // If preferred port is in use by another process, fall back to an ephemeral free port
84
+ this.server?.listen(0, () => {
85
+ const addr = this.server?.address();
86
+ const actualPort = typeof addr === 'object' && addr !== null ? addr.port : 0;
87
+ resolve({ url: `http://localhost:${actualPort}`, port: actualPort });
88
+ });
82
89
  }
83
90
  else {
84
91
  reject(err);
85
92
  }
86
93
  });
87
- this.server.listen(this.port, '0.0.0.0', () => {
94
+ // Bind dual-stack (no host = `::` with IPv4-mapped fallback) — NOT `0.0.0.0`. Chromium
95
+ // resolves `localhost` to `::1` first, so an IPv4-only server is missed whenever anything
96
+ // else answers IPv6 localhost. Note this does NOT guarantee EADDRINUSE against such a
97
+ // squatter: with SO_REUSEADDR a wildcard bind can silently coexist with a specific-bound
98
+ // socket, so callers must always follow the returned URL (or request port 0) instead of
99
+ // assuming the requested port is really ours.
100
+ this.server.listen(this.port, () => {
88
101
  const addr = this.server?.address();
89
102
  const actualPort = typeof addr === 'object' && addr !== null ? addr.port : this.port;
90
103
  resolve({ url: `http://localhost:${actualPort}`, port: actualPort });
@@ -0,0 +1,40 @@
1
+ export interface DemoServerState {
2
+ pid: number;
3
+ url: string;
4
+ port: number;
5
+ apiUrl?: string;
6
+ apiPort?: number;
7
+ startedAt: number;
8
+ }
9
+ export declare class DemoStateManager {
10
+ private static readonly STATE_FILENAME;
11
+ /**
12
+ * Resolves the primary project directory from `cwd` by locating `spectra.config.*`
13
+ * or falling back to searching upward for an existing `.testspectra` directory.
14
+ */
15
+ static resolveProjectDir(cwd?: string): string;
16
+ /**
17
+ * Returns candidate file paths for the demo server state file,
18
+ * checking the project-level `.testspectra` directory first, followed by the global home directory.
19
+ */
20
+ static getStateFilePaths(cwd?: string): string[];
21
+ /**
22
+ * Persists the active demo server state to disk for cross-process / cross-terminal discovery.
23
+ */
24
+ static saveState(state: DemoServerState, cwd?: string): void;
25
+ /**
26
+ * Removes demo server state files when the demo server terminates.
27
+ */
28
+ static removeState(cwd?: string): void;
29
+ /**
30
+ * Probes an HTTP server URL with a short timeout to verify it is responsive
31
+ * and optionally serves the TestSpectra demo web app.
32
+ */
33
+ static probeServer(url: string, timeoutMs?: number): Promise<boolean>;
34
+ /**
35
+ * Discovers whether a demo server session is actively running.
36
+ * If a state file exists and the target server responds to probe, returns the state.
37
+ * Stale state files (where the server does not respond) are automatically cleaned up.
38
+ */
39
+ static getActiveDemoServer(cwd?: string): Promise<DemoServerState | null>;
40
+ }
@@ -0,0 +1,157 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import http from 'http';
4
+ import os from 'os';
5
+ import { ConfigLoader } from '../config/loader.js';
6
+ export class DemoStateManager {
7
+ static STATE_FILENAME = 'demo-server.json';
8
+ /**
9
+ * Resolves the primary project directory from `cwd` by locating `spectra.config.*`
10
+ * or falling back to searching upward for an existing `.testspectra` directory.
11
+ */
12
+ static resolveProjectDir(cwd = process.cwd()) {
13
+ const configFile = ConfigLoader.findConfigFile(cwd);
14
+ if (configFile) {
15
+ return path.dirname(configFile);
16
+ }
17
+ let cur = path.resolve(cwd);
18
+ while (true) {
19
+ if (fs.existsSync(path.join(cur, '.testspectra'))) {
20
+ return cur;
21
+ }
22
+ const parent = path.dirname(cur);
23
+ if (parent === cur)
24
+ break;
25
+ cur = parent;
26
+ }
27
+ return path.resolve(cwd);
28
+ }
29
+ /**
30
+ * Returns candidate file paths for the demo server state file,
31
+ * checking the project-level `.testspectra` directory first, followed by the global home directory.
32
+ */
33
+ static getStateFilePaths(cwd = process.cwd()) {
34
+ const projectDir = this.resolveProjectDir(cwd);
35
+ const paths = [path.join(projectDir, '.testspectra', this.STATE_FILENAME)];
36
+ const homeDir = os.homedir();
37
+ const globalPath = path.join(homeDir, '.testspectra', this.STATE_FILENAME);
38
+ if (!paths.includes(globalPath)) {
39
+ paths.push(globalPath);
40
+ }
41
+ return paths;
42
+ }
43
+ /**
44
+ * Persists the active demo server state to disk for cross-process / cross-terminal discovery.
45
+ */
46
+ static saveState(state, cwd = process.cwd()) {
47
+ const filePaths = this.getStateFilePaths(cwd);
48
+ const content = JSON.stringify(state, null, 2);
49
+ for (const filePath of filePaths) {
50
+ try {
51
+ const dir = path.dirname(filePath);
52
+ if (!fs.existsSync(dir)) {
53
+ fs.mkdirSync(dir, { recursive: true });
54
+ }
55
+ fs.writeFileSync(filePath, content, 'utf-8');
56
+ }
57
+ catch {
58
+ // Best-effort write
59
+ }
60
+ }
61
+ }
62
+ /**
63
+ * Removes demo server state files when the demo server terminates.
64
+ */
65
+ static removeState(cwd = process.cwd()) {
66
+ const filePaths = this.getStateFilePaths(cwd);
67
+ for (const filePath of filePaths) {
68
+ try {
69
+ if (fs.existsSync(filePath)) {
70
+ fs.unlinkSync(filePath);
71
+ }
72
+ }
73
+ catch {
74
+ // Best-effort removal
75
+ }
76
+ }
77
+ }
78
+ /**
79
+ * Probes an HTTP server URL with a short timeout to verify it is responsive
80
+ * and optionally serves the TestSpectra demo web app.
81
+ */
82
+ static async probeServer(url, timeoutMs = 1000) {
83
+ return new Promise((resolve) => {
84
+ try {
85
+ const parsed = new URL(url);
86
+ const req = http.get({
87
+ hostname: parsed.hostname,
88
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
89
+ path: '/',
90
+ timeout: timeoutMs,
91
+ headers: {
92
+ 'User-Agent': 'TestSpectra-CLI-Probe',
93
+ },
94
+ }, (res) => {
95
+ // Confirm the server is responsive (status 200 or 3xx)
96
+ const isOk = (res.statusCode && res.statusCode >= 200 && res.statusCode < 400) || false;
97
+ res.resume(); // Drain stream
98
+ resolve(isOk);
99
+ });
100
+ req.on('timeout', () => {
101
+ req.destroy();
102
+ resolve(false);
103
+ });
104
+ req.on('error', () => {
105
+ resolve(false);
106
+ });
107
+ }
108
+ catch {
109
+ resolve(false);
110
+ }
111
+ });
112
+ }
113
+ /**
114
+ * Discovers whether a demo server session is actively running.
115
+ * If a state file exists and the target server responds to probe, returns the state.
116
+ * Stale state files (where the server does not respond) are automatically cleaned up.
117
+ */
118
+ static async getActiveDemoServer(cwd = process.cwd()) {
119
+ const filePaths = this.getStateFilePaths(cwd);
120
+ for (const filePath of filePaths) {
121
+ if (!fs.existsSync(filePath)) {
122
+ continue;
123
+ }
124
+ let parsed = null;
125
+ try {
126
+ const raw = fs.readFileSync(filePath, 'utf-8');
127
+ parsed = JSON.parse(raw);
128
+ }
129
+ catch {
130
+ // Corrupt file, clean it up
131
+ try {
132
+ fs.unlinkSync(filePath);
133
+ }
134
+ catch { }
135
+ continue;
136
+ }
137
+ if (!parsed || !parsed.url || typeof parsed.port !== 'number') {
138
+ try {
139
+ fs.unlinkSync(filePath);
140
+ }
141
+ catch { }
142
+ continue;
143
+ }
144
+ // Verify the server is actually responding
145
+ const isAlive = await this.probeServer(parsed.url);
146
+ if (isAlive) {
147
+ return parsed;
148
+ }
149
+ // Stale state file from killed/crashed session
150
+ try {
151
+ fs.unlinkSync(filePath);
152
+ }
153
+ catch { }
154
+ }
155
+ return null;
156
+ }
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testspectra/cli",
3
- "version": "1.1.11-rc.5",
3
+ "version": "1.1.12",
4
4
  "description": "TestSpectra Cross-Platform Test Runner CLI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,9 +26,9 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@testspectra/matchers": "^1.1.11-rc.5",
30
- "@testspectra/react": "^1.1.11-rc.5",
31
- "@testspectra/skills": "^1.1.11-rc.5",
29
+ "@testspectra/matchers": "^1.1.12",
30
+ "@testspectra/react": "^1.1.12",
31
+ "@testspectra/skills": "^1.1.12",
32
32
  "chalk": "^5.3.0",
33
33
  "commander": "^12.1.0",
34
34
  "cross-spawn": "^7.0.6",
@@ -36,12 +36,13 @@
36
36
  "ora": "^8.0.1",
37
37
  "typescript": "^5.6.3",
38
38
  "yaml": "^2.7.0",
39
- "zod": "^3.23.8"
39
+ "zod": "^3.23.8",
40
+ "@testspectra/reporter": "1.1.8-rc.29"
40
41
  },
41
42
  "optionalDependencies": {
42
- "@testspectra/cli-darwin-arm64": "1.1.11-rc.5",
43
- "@testspectra/cli-linux-x64": "1.1.11-rc.5",
44
- "@testspectra/cli-win32-x64": "1.1.11-rc.5"
43
+ "@testspectra/cli-darwin-arm64": "1.1.12",
44
+ "@testspectra/cli-linux-x64": "1.1.12",
45
+ "@testspectra/cli-win32-x64": "1.1.12"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/cross-spawn": "^6.0.6",
@@ -0,0 +1,14 @@
1
+ %PDF-1.4
2
+ 1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
3
+ 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
4
+ 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj
5
+ xref
6
+ 0 4
7
+ 0000000000 65535 f
8
+ 0000000009 00000 n
9
+ 0000000058 00000 n
10
+ 0000000115 00000 n
11
+ trailer<</Size 4/Root 1 0 R>>
12
+ startxref
13
+ 206
14
+ %%EOF
@@ -17,8 +17,8 @@
17
17
  "postinstall": "spectra sync-types"
18
18
  },
19
19
  "devDependencies": {
20
- "@testspectra/cli": "^1.1.11-rc.5",
21
- "@testspectra/matchers": "^1.1.11-rc.5",
20
+ "@testspectra/cli": "^1.1.12",
21
+ "@testspectra/matchers": "^1.1.12",
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,41 @@
1
+ class MatchersPage {
2
+ get visibleElement() { return Spectra.get("#demo-visible-el"); }
3
+ get hiddenElement() { return Spectra.get("#demo-hidden-el"); }
4
+ get existingElement() { return Spectra.get("#demo-existing-el"); }
5
+ get submitButton() { return Spectra.get("#demo-submit-btn"); }
6
+ get disabledButton() { return Spectra.get("#demo-disabled-btn"); }
7
+ get checkbox() { return Spectra.get("#demo-checkbox"); }
8
+ get radio() { return Spectra.get("#demo-radio"); }
9
+ get emailInput() { return Spectra.get("#demo-email-input"); }
10
+ get tokenInput() { return Spectra.get("#demo-token-input"); }
11
+ get exactText() { return Spectra.get("#demo-exact-text"); }
12
+ get partialText() { return Spectra.get("#demo-partial-text"); }
13
+ get activeTab() { return Spectra.get("#demo-active-tab"); }
14
+ get navLink() { return Spectra.get("#demo-nav-link"); }
15
+ get styledElement() { return Spectra.get("#demo-styled-el"); }
16
+ get focusInput() { return Spectra.get("#demo-focus-input"); }
17
+ get productCards() { return Spectra.getAll(".demo-product-card"); }
18
+ get emptyList() { return Spectra.getAll("#demo-empty-list > *"); }
19
+ get overviewSection() { return Spectra.get("#overview"); }
20
+ overviewSubtitle() { return this.overviewSection.get("#demo-page-subtitle"); }
21
+ overviewOutsideElement() { return this.overviewSection.get("#demo-submit-btn"); }
22
+
23
+ // API Interception & Mocking elements
24
+ get fetchApiButton() { return Spectra.get("#fetch-api-btn"); }
25
+ get fetchStatusBadge() { return Spectra.get("#fetch-status-badge"); }
26
+ get fetchedItems() { return Spectra.getAll(".api-fetched-item"); }
27
+ get fetchLoadingIndicator() { return Spectra.get("#fetch-loading-indicator"); }
28
+ get fetchCustomUrlInput() { return Spectra.get("#fetch-custom-url-input"); }
29
+ get postTitleInput() { return Spectra.get("#post-input-title"); }
30
+ get postBodyInput() { return Spectra.get("#post-input-body"); }
31
+ get submitPostButton() { return Spectra.get("#submit-post-btn"); }
32
+ get postStatusBadge() { return Spectra.get("#post-status-badge"); }
33
+ get postSuccessBanner() { return Spectra.get("#post-success-banner"); }
34
+ get postResponseJson() { return Spectra.get("#post-response-json"); }
35
+
36
+ async open(section = "") {
37
+ await Spectra.navigate("/" + (section ? "#" + section : ""));
38
+ }
39
+ }
40
+
41
+ export default new MatchersPage();
@@ -41,6 +41,12 @@ class MatchersPage {
41
41
 
42
42
  async open(section = "") {
43
43
  try {
44
+ // If a previous test or deep link left the app on a sub-screen, return to home first
45
+ const hasBackBtn = await Spectra.get("~back-to-home-btn").isDisplayed().catch(() => false);
46
+ if (hasBackBtn) {
47
+ await Spectra.get("~back-to-home-btn").click();
48
+ }
49
+
44
50
  // A fresh app process (relaunched by the orchestrator's bootstrap before every suite) has to
45
51
  // fetch, parse, and execute the RN JS bundle from Metro before anything is on screen — on a
46
52
  // cold start this can take well past a normal action's poll budget. Wait for the home screen
@@ -48,6 +54,7 @@ class MatchersPage {
48
54
  // real failure to surface confusingly on whatever element the test touches next.
49
55
  await Spectra.get("~toggle-sidebar-btn").waitForElement(30000);
50
56
 
57
+ const target = section || "overview";
51
58
  const isDrawerOpen = await Spectra.get("~close-sidebar-btn").isDisplayed().catch(() => false);
52
59
  if (!isDrawerOpen) {
53
60
  await Spectra.get("~toggle-sidebar-btn").click();
@@ -60,9 +67,7 @@ class MatchersPage {
60
67
  // the Modal is slow to materialize.
61
68
  await Spectra.get("~close-sidebar-btn").waitForElement(10000);
62
69
  }
63
- if (section) {
64
- await Spectra.get(`~nav-item-${section}`).click();
65
- }
70
+ await Spectra.get(`~nav-item-${target}`).click();
66
71
  } catch {}
67
72
  }
68
73
  }
@@ -20,6 +20,12 @@ class PlaygroundPage {
20
20
 
21
21
  async open() {
22
22
  try {
23
+ // If a previous test or deep link left the app on a sub-screen, return to home first
24
+ const hasBackBtn = await Spectra.get("~back-to-home-btn").isDisplayed().catch(() => false);
25
+ if (hasBackBtn) {
26
+ await Spectra.get("~back-to-home-btn").click();
27
+ }
28
+
23
29
  // See MatchersPage.open()'s equivalent comment: waits for the cold-start home screen, then
24
30
  // for the drawer Modal to actually finish materializing before clicking a nav item inside
25
31
  // it — both races that otherwise surface as an occasional ~10s stall on the next click.
@@ -17,6 +17,8 @@ class PlaygroundPage {
17
17
  get focusInput() { return Spectra.get("#demo-focus-input-pg"); }
18
18
  get productItems() { return Spectra.getAll(".demo-product-item"); }
19
19
  get emptyList() { return Spectra.getAll("#demo-empty-list-pg > *"); }
20
+ get fileInput() { return Spectra.get("#demo-file-input-pg"); }
21
+ get uploadedFilename() { return Spectra.get("#demo-uploaded-filename"); }
20
22
 
21
23
  async open() {
22
24
  await Spectra.navigate("/#playground");
@@ -4,4 +4,7 @@ it("Seed an authenticated session via Spectra.seedSession() instead of the real
4
4
  await Spectra.seedSession();
5
5
 
6
6
  await AuthPage.statusBadge.shouldHaveText("Authenticated as demo@testspectra.dev");
7
+
8
+ // Return to the home screen so later suites in the same batch don't inherit this route.
9
+ await Spectra.get("~back-to-home-btn").click();
7
10
  });
@@ -1,6 +1,9 @@
1
1
  it("Verify page title and sidebar navigation links", async () => {
2
2
  await MatchersPage.open();
3
3
 
4
+ // Open sidebar drawer to view navigation links
5
+ await NavigationPage.toggleSidebarButton.click();
6
+
4
7
  // Navigation sidebar collection verification
5
8
  await NavigationPage.sidebarLinks.shouldHaveLengthGreaterThan(0);
6
9
 
@@ -8,7 +8,7 @@ it("Verify page title and sidebar navigation links", async () => {
8
8
  // Browser URL assertions
9
9
  const currentUrl = await browser.getUrl();
10
10
  await Spectra.browser.shouldHaveUrl(currentUrl);
11
- await Spectra.browser.shouldContainUrl("localhost:5173");
11
+ await Spectra.browser.shouldContainUrl("localhost");
12
12
 
13
13
  // Page lifecycle and console error diagnostics
14
14
  await Spectra.browser.shouldBeLoaded();
@@ -0,0 +1,11 @@
1
+ it("Verify uploading image and document fixtures to file input", async () => {
2
+ await PlaygroundPage.open();
3
+
4
+ // 1. Upload sample image fixture
5
+ await PlaygroundPage.fileInput.upload(Fixture.sampleImage);
6
+ await PlaygroundPage.uploadedFilename.shouldContainText("sampleImage");
7
+
8
+ // 2. Upload sample PDF document fixture
9
+ await PlaygroundPage.fileInput.upload(Fixture.sampleDocument);
10
+ await PlaygroundPage.uploadedFilename.shouldContainText("sampleDocument");
11
+ });
@@ -2,11 +2,13 @@ import { defineConfig } from "@testspectra/cli";
2
2
 
3
3
  export default defineConfig({
4
4
  webConfig: {
5
- baseUrl: "http://localhost:5173",
5
+ baseUrl: "http://localhost:5180",
6
6
  maxConcurrentSessions: "1",
7
7
  headless: true,
8
8
  implicitWait: "5000",
9
9
  parallelizationMode: "testcase",
10
+ // Failure-only video recording (default: true)
11
+ video: true,
10
12
  },
11
13
  androidConfig: {
12
14
  driverServer: "tcp://127.0.0.1:8200",
@@ -15,6 +17,8 @@ export default defineConfig({
15
17
  noReset: false,
16
18
  implicitWait: "10000",
17
19
  parallelizationMode: "suite",
20
+ // Failure-only video recording (default: true)
21
+ video: true,
18
22
  devices: [
19
23
  {
20
24
  id: "emulator-5554",