@testspectra/cli 1.1.11-rc.3 → 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 (90) 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/__tests__/run.test.js +27 -0
  7. package/dist/commands/demo.d.ts +1 -0
  8. package/dist/commands/demo.js +26 -4
  9. package/dist/commands/doctor.js +23 -0
  10. package/dist/commands/run.d.ts +6 -0
  11. package/dist/commands/run.js +51 -5
  12. package/dist/commands/test.d.ts +9 -9
  13. package/dist/commands/test.js +40 -75
  14. package/dist/commands/update.d.ts +3 -0
  15. package/dist/commands/update.js +109 -0
  16. package/dist/config/loader.js +31 -4
  17. package/dist/config/schema.d.ts +60 -2
  18. package/dist/config/schema.js +4 -1
  19. package/dist/generator/__tests__/tsconfig-isolation.test.js +21 -0
  20. package/dist/generator/__tests__/type-generator.test.js +15 -1
  21. package/dist/generator/tsconfig-generator.js +1 -0
  22. package/dist/generator/type-generator.js +2 -0
  23. package/dist/index.js +11 -4
  24. package/dist/lifecycle/__tests__/lifecycle-engine.test.js +290 -0
  25. package/dist/lifecycle/hook-discovery.d.ts +24 -0
  26. package/dist/lifecycle/hook-discovery.js +60 -0
  27. package/dist/lifecycle/hook-dispatcher.d.ts +34 -0
  28. package/dist/lifecycle/hook-dispatcher.js +190 -0
  29. package/dist/lifecycle/index.d.ts +3 -0
  30. package/dist/lifecycle/index.js +3 -0
  31. package/dist/lifecycle/parallel-grouping.d.ts +27 -0
  32. package/dist/lifecycle/parallel-grouping.js +151 -0
  33. package/dist/reporter/semantic-formatter.js +2 -0
  34. package/dist/reporter/types.d.ts +1 -1
  35. package/dist/runner/bridge.d.ts +2 -0
  36. package/dist/runner/bridge.js +4 -1
  37. package/dist/runner/reporter.d.ts +5 -43
  38. package/dist/runner/reporter.js +46 -179
  39. package/dist/utils/__tests__/demo-state.test.d.ts +1 -0
  40. package/dist/utils/__tests__/demo-state.test.js +96 -0
  41. package/dist/utils/api-server.d.ts +1 -1
  42. package/dist/utils/api-server.js +7 -3
  43. package/dist/utils/demo-server.d.ts +2 -0
  44. package/dist/utils/demo-server.js +18 -5
  45. package/dist/utils/demo-state.d.ts +40 -0
  46. package/dist/utils/demo-state.js +157 -0
  47. package/package.json +9 -8
  48. package/templates/default/fixtures/sampleDocument.pdf +14 -0
  49. package/templates/default/fixtures/sampleImage.png +0 -0
  50. package/templates/default/package.json +2 -2
  51. package/templates/default/page-objects/MatchersPage/common.ts +41 -0
  52. package/templates/default/page-objects/MatchersPage/mobile.ts +8 -3
  53. package/templates/default/page-objects/PlaygroundPage/mobile.ts +6 -0
  54. package/templates/default/page-objects/PlaygroundPage/web.ts +2 -0
  55. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +3 -0
  56. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +3 -0
  57. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  58. package/templates/default/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  59. package/templates/default/spectra.config.ts +5 -1
  60. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db +0 -0
  61. package/templates/nx/.nx/workspace-data/d/daemon.log +7063 -1012
  62. package/templates/nx/.nx/workspace-data/file-map.json +181 -169
  63. package/templates/nx/.nx/workspace-data/lockfile.hash +1 -1
  64. package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
  65. package/templates/nx/.nx/workspace-data/parsed-lock-file.json +81 -130
  66. package/templates/nx/.nx/workspace-data/project-graph.json +83 -138
  67. package/templates/nx/package.json +2 -2
  68. package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  69. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  70. package/templates/nx/shared/testing/fixtures/sampleDocument.pdf +14 -0
  71. package/templates/nx/shared/testing/fixtures/sampleImage.png +0 -0
  72. package/templates/nx/shared/testing/page-objects/MatchersPage/mobile.ts +68 -26
  73. package/templates/nx/shared/testing/page-objects/PlaygroundPage/mobile.ts +39 -17
  74. package/templates/nx/shared/testing/page-objects/PlaygroundPage/web.ts +2 -0
  75. package/templates/nx/spectra.config.ts +3 -3
  76. package/templates/react-component/fixtures/sampleDocument.pdf +14 -0
  77. package/templates/react-component/fixtures/sampleImage.png +0 -0
  78. package/templates/react-component/package.json +3 -3
  79. package/templates/react-component/page-objects/FileUploadComponent/web.ts +15 -0
  80. package/templates/react-component/specs/FileUploadComponent/TC-0001-upload-fixtures/web.test.tsx +18 -0
  81. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/spec.md +20 -0
  82. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/web.test.tsx +15 -0
  83. package/templates/react-component/specs/UserListComponent/suite.md +12 -0
  84. package/templates/react-component/src/components/FileUpload/FileUpload.tsx +44 -0
  85. package/templates/react-component/src/components/UserList/UserList.tsx +48 -0
  86. package/bin/.testspectra-runner.hash +0 -1
  87. package/demo-app/assets/index-BhlW-eXG.css +0 -1
  88. package/demo-app/assets/index-DOtRypCa.js +0 -154
  89. package/dist/commands/__tests__/doctor-scope.test.js +0 -24
  90. /package/dist/{commands/__tests__/doctor-scope.test.d.ts → lifecycle/__tests__/lifecycle-engine.test.d.ts} +0 -0
@@ -1,169 +1,14 @@
1
+ import { formatAction, formatAssertion, parseAndFormat, SemanticReporter, stripAnsi, } from '@testspectra/reporter';
1
2
  import chalk from 'chalk';
2
- import { formatAction, formatAssertion } from '../reporter/semantic-formatter.js';
3
- function stripAnsi(str) {
4
- return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
5
- }
6
- function mapActionArgs(key, arr) {
7
- if (!Array.isArray(arr)) {
8
- return typeof arr === 'object' && arr !== null ? arr : {};
9
- }
10
- switch (key) {
11
- case 'type':
12
- return { text: arr[0] };
13
- case 'select':
14
- case 'selectOption':
15
- return { option: arr[0] };
16
- case 'dragDrop':
17
- return { dest: arr[0] };
18
- case 'longPress':
19
- return { durationMs: arr[0] };
20
- case 'navigate':
21
- return { url: arr[0] };
22
- case 'setCookies':
23
- return { url: arr[0] };
24
- case 'setViewport':
25
- return { width: arr[0], height: arr[1] };
26
- case 'wait':
27
- return { ms: arr[0] };
28
- case 'pressKey':
29
- return { key: arr[0] };
30
- case 'scroll':
31
- return { direction: arr[0], pixels: arr[1] };
32
- case 'swipe':
33
- return { direction: arr[0], distance: arr[1] };
34
- case 'intercept':
35
- return { method: arr[0], url: arr[1] };
36
- default:
37
- return {};
38
- }
39
- }
40
- function mapAssertionArgs(key, arr) {
41
- if (!Array.isArray(arr)) {
42
- return typeof arr === 'object' && arr !== null ? arr : { expected: arr };
43
- }
44
- if (key === 'have.attr' || key === 'not.have.attr') {
45
- return arr.length >= 2 ? { name: arr[0], value: arr[1], expected: arr[0] } : { name: arr[0], expected: arr[0] };
46
- }
47
- if (key === 'have.css' || key === 'not.have.css') {
48
- return { name: arr[0], property: arr[0], value: arr[1], expected: arr[0] };
49
- }
50
- if (arr.length >= 2) {
51
- return { name: arr[0], value: arr[1], expected: arr[0] };
52
- }
53
- return {
54
- expected: arr[0],
55
- name: arr[0],
56
- text: arr[0],
57
- value: arr[0],
58
- title: arr[0],
59
- url: arr[0],
60
- length: arr[0],
61
- min: arr[0],
62
- max: arr[0],
63
- };
64
- }
65
- function parseAndFormat(cmdText) {
66
- try {
67
- const payload = JSON.parse(cmdText);
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;
73
- if (payload.type === 'action') {
74
- return formatAction(payload.key, target, mapActionArgs(payload.key, payload.args || []));
75
- }
76
- else if (payload.type === 'assertion' || payload.type === 'browser') {
77
- return formatAssertion(payload.key, target, mapAssertionArgs(payload.key, payload.args || []));
78
- }
79
- // Native Orchestrator payload format ({ test, action, selector, target, ... })
80
- if (payload.action) {
81
- const action = payload.action;
82
- const t = payload.selector || payload.target;
83
- switch (action) {
84
- case 'navigate':
85
- return formatAction('navigate', undefined, {
86
- url: t || payload.url || payload.value || 'http://localhost:5173',
87
- });
88
- case 'click':
89
- return formatAction('click', t);
90
- case 'double_click':
91
- case 'doubleClick':
92
- return formatAction('doubleClick', t);
93
- case 'right_click':
94
- case 'rightClick':
95
- return formatAction('rightClick', t);
96
- case 'type':
97
- case 'type_text':
98
- case 'set_value':
99
- return formatAction('type', t, { text: payload.text || payload.value });
100
- case 'clear':
101
- return formatAction('clear', t);
102
- case 'hover':
103
- return formatAction('hover', t);
104
- case 'scroll_into_view':
105
- case 'scrollIntoView':
106
- return formatAction('scrollIntoView', t);
107
- case 'should_be_visible':
108
- return formatAssertion('be.visible', t);
109
- case 'should_not_be_visible':
110
- return formatAssertion('not.be.visible', t);
111
- case 'should_exist':
112
- return formatAssertion('exist', t);
113
- case 'should_not_exist':
114
- return formatAssertion('not.exist', t);
115
- case 'should_be_enabled':
116
- return formatAssertion('be.enabled', t);
117
- case 'should_be_disabled':
118
- return formatAssertion('be.disabled', t);
119
- case 'should_be_clickable':
120
- return formatAssertion('be.clickable', t);
121
- case 'should_have_text':
122
- return formatAssertion('have.text', t, { expected: payload.expected || payload.text || payload.value });
123
- case 'should_contain_text':
124
- return formatAssertion('contain.text', t, {
125
- expected: payload.expected || payload.text || payload.value,
126
- });
127
- case 'should_have_value':
128
- return formatAssertion('have.value', t, { expected: payload.expected || payload.value });
129
- case 'should_contain_value':
130
- return formatAssertion('contain.value', t, { expected: payload.expected || payload.value });
131
- case 'should_have_length':
132
- return formatAssertion('have.length', t, {
133
- expected: payload.expected || payload.length || payload.value,
134
- });
135
- case 'should_have_title':
136
- return formatAssertion('have.title', undefined, {
137
- expected: payload.expected || payload.value || t || 'TestSpectra',
138
- });
139
- case 'should_be_empty':
140
- return formatAssertion('be.empty', t);
141
- case 'should_not_be_empty':
142
- return formatAssertion('not.be.empty', t);
143
- default:
144
- return `Execute step "${action}" on ${t || 'target'}`;
145
- }
146
- }
147
- return cmdText;
148
- }
149
- catch {
150
- return cmdText;
151
- }
152
- }
153
- export class Reporter {
154
- logs = [];
155
- networkEvents = [];
156
- pendingTests = new Map();
157
- workerToTest = new Map();
158
- pendingErrorMsg;
159
- passedCount = 0;
160
- failedCount = 0;
3
+ export { formatAction, formatAssertion, parseAndFormat, SemanticReporter, stripAnsi, };
4
+ export class Reporter extends SemanticReporter {
161
5
  isInteractive;
162
6
  lastRenderedLines = 0;
163
7
  spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
164
8
  spinnerIndex = 0;
165
9
  spinnerTimer = null;
166
- constructor() {
10
+ constructor(options = {}) {
11
+ super(options);
167
12
  this.isInteractive = Boolean(process.stdout.isTTY && !process.env.CI && process.env.NODE_ENV !== 'test' && !process.env.VITEST);
168
13
  if (this.isInteractive) {
169
14
  this.spinnerTimer = setInterval(() => {
@@ -211,23 +56,16 @@ export class Reporter {
211
56
  }
212
57
  this.lastRenderedLines = linesCount;
213
58
  }
214
- resolveTestCase(testTitle, workerId) {
215
- if (testTitle && this.pendingTests.has(testTitle)) {
216
- return this.pendingTests.get(testTitle);
217
- }
218
- if (workerId !== undefined && this.workerToTest.has(workerId)) {
219
- const title = this.workerToTest.get(workerId);
220
- if (title && this.pendingTests.has(title)) {
221
- return this.pendingTests.get(title);
59
+ addLog(input) {
60
+ const log = typeof input === 'string'
61
+ ? {
62
+ timestamp: new Date().toISOString(),
63
+ level: 'INFO',
64
+ message: input,
222
65
  }
223
- }
224
- if (this.pendingTests.size === 1) {
225
- return this.pendingTests.values().next().value;
226
- }
227
- return Array.from(this.pendingTests.values()).pop();
228
- }
229
- addLog(log) {
66
+ : input;
230
67
  this.logs.push(log);
68
+ this.options.onLog?.(log);
231
69
  const raw = stripAnsi(log.message || '').trim();
232
70
  if (!raw)
233
71
  return;
@@ -284,6 +122,7 @@ export class Reporter {
284
122
  this.workerToTest.set(workerId, title);
285
123
  }
286
124
  this.renderActiveSlots();
125
+ this.options.onTestStart?.(pending);
287
126
  return;
288
127
  }
289
128
  // 2. Step in-progress / start
@@ -303,6 +142,7 @@ export class Reporter {
303
142
  testCase.currentStep = formatted;
304
143
  this.renderActiveSlots();
305
144
  }
145
+ this.options.onStepStart?.(testTitle || testCase?.title || '', formatted, workerId ?? testCase?.workerId);
306
146
  return;
307
147
  }
308
148
  // 3. Step passed -> record step to matching test case
@@ -321,11 +161,13 @@ export class Reporter {
321
161
  }
322
162
  catch { }
323
163
  const testCase = this.resolveTestCase(testTitle, workerId);
164
+ const step = { text: formatted, duration, passed: true };
324
165
  if (testCase) {
325
- testCase.steps.push({ text: formatted, duration, passed: true });
166
+ testCase.steps.push(step);
326
167
  testCase.currentStep = formatted;
327
168
  this.renderActiveSlots();
328
169
  }
170
+ this.options.onStepPass?.(testTitle || testCase?.title || '', step, workerId ?? testCase?.workerId);
329
171
  return;
330
172
  }
331
173
  // 4. Step failed -> record failed step to matching test case
@@ -344,11 +186,28 @@ export class Reporter {
344
186
  }
345
187
  catch { }
346
188
  const testCase = this.resolveTestCase(testTitle, workerId);
189
+ const step = { text: formatted, duration, passed: false };
347
190
  if (testCase) {
348
- testCase.steps.push({ text: formatted, duration, passed: false });
191
+ testCase.steps.push(step);
349
192
  testCase.currentStep = formatted;
350
193
  this.renderActiveSlots();
351
194
  }
195
+ this.options.onStepFail?.(testTitle || testCase?.title || '', step, workerId ?? testCase?.workerId);
196
+ return;
197
+ }
198
+ // 4.5 Video capture notification
199
+ if (raw.startsWith('[TESTSPECTRA_VIDEO]')) {
200
+ const content = raw.replace('[TESTSPECTRA_VIDEO]', '').trim();
201
+ try {
202
+ const payload = JSON.parse(content);
203
+ if (payload.videoPath) {
204
+ const testCase = this.resolveTestCase(payload.id);
205
+ if (testCase) {
206
+ testCase.videoPath = payload.videoPath;
207
+ }
208
+ }
209
+ }
210
+ catch { }
352
211
  return;
353
212
  }
354
213
  // 5. Test error detail
@@ -361,6 +220,7 @@ export class Reporter {
361
220
  else {
362
221
  this.pendingErrorMsg = this.pendingErrorMsg ? `${this.pendingErrorMsg}\n${err}` : err;
363
222
  }
223
+ this.options.onTestError?.(err, latest?.title, latest?.workerId);
364
224
  return;
365
225
  }
366
226
  // 6. Test passed completely -> flush atomic test block to permanent history
@@ -384,6 +244,7 @@ export class Reporter {
384
244
  }
385
245
  console.log('');
386
246
  this.renderActiveSlots();
247
+ this.options.onTestPass?.({ title, duration, steps, workerId });
387
248
  return;
388
249
  }
389
250
  // 7. Test failed completely -> flush atomic failed test block to permanent history
@@ -397,6 +258,7 @@ export class Reporter {
397
258
  const steps = testCase?.steps || [];
398
259
  const errorMsg = testCase?.errorMsg || this.pendingErrorMsg;
399
260
  this.pendingErrorMsg = undefined;
261
+ const videoPath = testCase?.videoPath;
400
262
  const workerId = testCase?.workerId;
401
263
  this.pendingTests.delete(title);
402
264
  if (workerId !== undefined) {
@@ -419,17 +281,21 @@ export class Reporter {
419
281
  console.log(` \x1b[38;2;248;113;113m${errLines[i]}\x1b[0m`);
420
282
  }
421
283
  }
284
+ if (videoPath) {
285
+ console.log(` \x1b[38;2;251;191;36m↳ Video:\x1b[0m \x1b[38;2;251;191;36m${videoPath}\x1b[0m`);
286
+ }
422
287
  console.log('');
423
288
  this.renderActiveSlots();
289
+ this.options.onTestFail?.({ title, duration, steps, errorMsg, workerId });
424
290
  return;
425
291
  }
426
292
  // Generic errors & warnings
427
- if (log.level === 'ERROR' || log.level === 'FAILED') {
293
+ if (log.level === 'ERROR') {
428
294
  this.clearActiveSlots();
429
295
  console.log(` \x1b[38;2;248;113;113m${log.message}\x1b[0m`);
430
296
  this.renderActiveSlots();
431
297
  }
432
- else if (log.level === 'WARN' || log.level === 'WARNING') {
298
+ else if (log.level === 'WARNING') {
433
299
  this.clearActiveSlots();
434
300
  console.log(` \x1b[38;2;251;191;36m${log.message}\x1b[0m`);
435
301
  this.renderActiveSlots();
@@ -437,6 +303,7 @@ export class Reporter {
437
303
  }
438
304
  addNetwork(res) {
439
305
  this.networkEvents.push(res);
306
+ this.options.onNetwork?.(res);
440
307
  }
441
308
  getLogs() {
442
309
  return this.logs;
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ }