@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.
- package/demo-app/assets/index-DDYS5fLp.css +1 -0
- package/demo-app/assets/index-euCENPMy.js +154 -0
- package/demo-app/index.html +2 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/commands/__tests__/run-e2e.test.js +24 -6
- package/dist/commands/__tests__/run.test.js +27 -0
- package/dist/commands/demo.d.ts +1 -0
- package/dist/commands/demo.js +26 -4
- package/dist/commands/doctor.js +23 -0
- package/dist/commands/run.d.ts +6 -0
- package/dist/commands/run.js +51 -5
- package/dist/commands/test.d.ts +9 -9
- package/dist/commands/test.js +40 -75
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.js +109 -0
- package/dist/config/loader.js +31 -4
- package/dist/config/schema.d.ts +60 -2
- package/dist/config/schema.js +4 -1
- package/dist/generator/__tests__/tsconfig-isolation.test.js +21 -0
- package/dist/generator/__tests__/type-generator.test.js +15 -1
- package/dist/generator/tsconfig-generator.js +1 -0
- package/dist/generator/type-generator.js +2 -0
- package/dist/index.js +11 -4
- package/dist/lifecycle/__tests__/lifecycle-engine.test.js +290 -0
- package/dist/lifecycle/hook-discovery.d.ts +24 -0
- package/dist/lifecycle/hook-discovery.js +60 -0
- package/dist/lifecycle/hook-dispatcher.d.ts +34 -0
- package/dist/lifecycle/hook-dispatcher.js +190 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.js +3 -0
- package/dist/lifecycle/parallel-grouping.d.ts +27 -0
- package/dist/lifecycle/parallel-grouping.js +151 -0
- package/dist/reporter/semantic-formatter.js +2 -0
- package/dist/reporter/types.d.ts +1 -1
- package/dist/runner/bridge.d.ts +2 -0
- package/dist/runner/bridge.js +4 -1
- package/dist/runner/reporter.d.ts +5 -43
- package/dist/runner/reporter.js +46 -179
- package/dist/utils/__tests__/demo-state.test.d.ts +1 -0
- package/dist/utils/__tests__/demo-state.test.js +96 -0
- package/dist/utils/api-server.d.ts +1 -1
- package/dist/utils/api-server.js +7 -3
- package/dist/utils/demo-server.d.ts +2 -0
- package/dist/utils/demo-server.js +18 -5
- package/dist/utils/demo-state.d.ts +40 -0
- package/dist/utils/demo-state.js +157 -0
- package/package.json +9 -8
- package/templates/default/fixtures/sampleDocument.pdf +14 -0
- package/templates/default/fixtures/sampleImage.png +0 -0
- package/templates/default/package.json +2 -2
- package/templates/default/page-objects/MatchersPage/common.ts +41 -0
- package/templates/default/page-objects/MatchersPage/mobile.ts +8 -3
- package/templates/default/page-objects/PlaygroundPage/mobile.ts +6 -0
- package/templates/default/page-objects/PlaygroundPage/web.ts +2 -0
- package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +3 -0
- package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +3 -0
- package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
- package/templates/default/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
- package/templates/default/spectra.config.ts +5 -1
- package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db +0 -0
- package/templates/nx/.nx/workspace-data/d/daemon.log +7063 -1012
- package/templates/nx/.nx/workspace-data/file-map.json +181 -169
- package/templates/nx/.nx/workspace-data/lockfile.hash +1 -1
- package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
- package/templates/nx/.nx/workspace-data/parsed-lock-file.json +81 -130
- package/templates/nx/.nx/workspace-data/project-graph.json +83 -138
- package/templates/nx/package.json +2 -2
- package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
- package/templates/nx/packages/playground/e2e/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
- package/templates/nx/shared/testing/fixtures/sampleDocument.pdf +14 -0
- package/templates/nx/shared/testing/fixtures/sampleImage.png +0 -0
- package/templates/nx/shared/testing/page-objects/MatchersPage/mobile.ts +68 -26
- package/templates/nx/shared/testing/page-objects/PlaygroundPage/mobile.ts +39 -17
- package/templates/nx/shared/testing/page-objects/PlaygroundPage/web.ts +2 -0
- package/templates/nx/spectra.config.ts +3 -3
- package/templates/react-component/fixtures/sampleDocument.pdf +14 -0
- package/templates/react-component/fixtures/sampleImage.png +0 -0
- package/templates/react-component/package.json +3 -3
- package/templates/react-component/page-objects/FileUploadComponent/web.ts +15 -0
- package/templates/react-component/specs/FileUploadComponent/TC-0001-upload-fixtures/web.test.tsx +18 -0
- package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/spec.md +20 -0
- package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/web.test.tsx +15 -0
- package/templates/react-component/specs/UserListComponent/suite.md +12 -0
- package/templates/react-component/src/components/FileUpload/FileUpload.tsx +44 -0
- package/templates/react-component/src/components/UserList/UserList.tsx +48 -0
- package/bin/.testspectra-runner.hash +0 -1
- package/demo-app/assets/index-BhlW-eXG.css +0 -1
- package/demo-app/assets/index-DOtRypCa.js +0 -154
- package/dist/commands/__tests__/doctor-scope.test.js +0 -24
- /package/dist/{commands/__tests__/doctor-scope.test.d.ts → lifecycle/__tests__/lifecycle-engine.test.d.ts} +0 -0
package/dist/runner/reporter.js
CHANGED
|
@@ -1,169 +1,14 @@
|
|
|
1
|
+
import { formatAction, formatAssertion, parseAndFormat, SemanticReporter, stripAnsi, } from '@testspectra/reporter';
|
|
1
2
|
import chalk from 'chalk';
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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(
|
|
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'
|
|
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 === '
|
|
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
|
|
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
|
package/dist/utils/api-server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
81
|
-
|
|
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
|
-
|
|
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
|
+
}
|