creevey 0.10.0-beta.43 → 0.10.0-beta.45

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 (137) hide show
  1. package/CHANGELOG.md +282 -0
  2. package/dist/client/addon/controller.js +1 -1
  3. package/dist/client/addon/controller.js.map +1 -1
  4. package/dist/client/addon/withCreevey.js +1 -18
  5. package/dist/client/addon/withCreevey.js.map +1 -1
  6. package/dist/client/shared/components/PageHeader/PageHeader.js +13 -4
  7. package/dist/client/shared/components/PageHeader/PageHeader.js.map +1 -1
  8. package/dist/client/shared/creeveyClientApi.js +10 -0
  9. package/dist/client/shared/creeveyClientApi.js.map +1 -1
  10. package/dist/client/web/CreeveyApp.d.ts +1 -0
  11. package/dist/client/web/CreeveyApp.js +1 -0
  12. package/dist/client/web/CreeveyApp.js.map +1 -1
  13. package/dist/client/web/CreeveyContext.d.ts +1 -0
  14. package/dist/client/web/CreeveyContext.js +1 -0
  15. package/dist/client/web/CreeveyContext.js.map +1 -1
  16. package/dist/client/web/CreeveyView/SideBar/SideBarFooter.js +9 -8
  17. package/dist/client/web/CreeveyView/SideBar/SideBarFooter.js.map +1 -1
  18. package/dist/client/web/CreeveyView/SideBar/SideBarHeader.js +13 -3
  19. package/dist/client/web/CreeveyView/SideBar/SideBarHeader.js.map +1 -1
  20. package/dist/client/web/CreeveyView/SideBar/SuiteLink.js +2 -3
  21. package/dist/client/web/CreeveyView/SideBar/SuiteLink.js.map +1 -1
  22. package/dist/client/web/CreeveyView/SideBar/TestLink.js +2 -3
  23. package/dist/client/web/CreeveyView/SideBar/TestLink.js.map +1 -1
  24. package/dist/client/web/CreeveyView/SideBar/TestsStatus.js +1 -0
  25. package/dist/client/web/CreeveyView/SideBar/TestsStatus.js.map +1 -1
  26. package/dist/client/web/assets/{index-C47njyZV.js → index-BU4jjKVC.js} +68 -68
  27. package/dist/client/web/index.html +1 -1
  28. package/dist/client/web/index.js +8 -3
  29. package/dist/client/web/index.js.map +1 -1
  30. package/dist/creevey.d.ts +1 -1
  31. package/dist/creevey.js +1 -22
  32. package/dist/creevey.js.map +1 -1
  33. package/dist/playwright-reporter.d.ts +2 -0
  34. package/dist/playwright-reporter.js +5 -0
  35. package/dist/playwright-reporter.js.map +1 -0
  36. package/dist/playwright.d.ts +1 -1
  37. package/dist/server/config.js +8 -1
  38. package/dist/server/config.js.map +1 -1
  39. package/dist/server/index.js +12 -5
  40. package/dist/server/index.js.map +1 -1
  41. package/dist/server/master/api.d.ts +11 -6
  42. package/dist/server/master/api.js +88 -25
  43. package/dist/server/master/api.js.map +1 -1
  44. package/dist/server/master/handlers/capture-handler.d.ts +5 -0
  45. package/dist/server/master/handlers/capture-handler.js +25 -0
  46. package/dist/server/master/handlers/capture-handler.js.map +1 -0
  47. package/dist/server/master/handlers/index.d.ts +4 -0
  48. package/dist/server/master/handlers/index.js +21 -0
  49. package/dist/server/master/handlers/index.js.map +1 -0
  50. package/dist/server/master/handlers/ping-handler.d.ts +2 -0
  51. package/dist/server/master/handlers/ping-handler.js +8 -0
  52. package/dist/server/master/handlers/ping-handler.js.map +1 -0
  53. package/dist/server/master/handlers/static-handler.d.ts +1 -0
  54. package/dist/server/master/handlers/static-handler.js +22 -0
  55. package/dist/server/master/handlers/static-handler.js.map +1 -0
  56. package/dist/server/master/handlers/stories-handler.d.ts +4 -0
  57. package/dist/server/master/handlers/stories-handler.js +24 -0
  58. package/dist/server/master/handlers/stories-handler.js.map +1 -0
  59. package/dist/server/master/master.js +7 -24
  60. package/dist/server/master/master.js.map +1 -1
  61. package/dist/server/master/runner.d.ts +4 -6
  62. package/dist/server/master/runner.js +30 -127
  63. package/dist/server/master/runner.js.map +1 -1
  64. package/dist/server/master/server.js +191 -89
  65. package/dist/server/master/server.js.map +1 -1
  66. package/dist/server/master/start.d.ts +1 -2
  67. package/dist/server/master/start.js +11 -29
  68. package/dist/server/master/start.js.map +1 -1
  69. package/dist/server/master/testsManager.d.ts +81 -0
  70. package/dist/server/master/testsManager.js +281 -0
  71. package/dist/server/master/testsManager.js.map +1 -0
  72. package/dist/server/playwright/docker-file.js +2 -2
  73. package/dist/server/playwright/docker-file.js.map +1 -1
  74. package/dist/server/playwright/reporter.d.ts +87 -0
  75. package/dist/server/playwright/reporter.js +351 -0
  76. package/dist/server/playwright/reporter.js.map +1 -0
  77. package/dist/server/selenium/internal.js +20 -2
  78. package/dist/server/selenium/internal.js.map +1 -1
  79. package/dist/server/selenium/selenoid.js +4 -0
  80. package/dist/server/selenium/selenoid.js.map +1 -1
  81. package/dist/server/shutdown.d.ts +1 -0
  82. package/dist/server/shutdown.js +23 -0
  83. package/dist/server/shutdown.js.map +1 -0
  84. package/dist/server/stories.d.ts +0 -1
  85. package/dist/server/stories.js +0 -12
  86. package/dist/server/stories.js.map +1 -1
  87. package/dist/server/ui-update.d.ts +10 -0
  88. package/dist/server/ui-update.js +39 -0
  89. package/dist/server/ui-update.js.map +1 -0
  90. package/dist/server/utils.d.ts +6 -0
  91. package/dist/server/utils.js +39 -8
  92. package/dist/server/utils.js.map +1 -1
  93. package/dist/server/worker/start.js +1 -1
  94. package/dist/server/worker/start.js.map +1 -1
  95. package/dist/types.d.ts +14 -8
  96. package/dist/types.js.map +1 -1
  97. package/docs/examples/playwright-reporter-example.ts +202 -0
  98. package/docs/migration-0.9-to-0.10.md +144 -0
  99. package/docs/playwright-reporter.md +357 -0
  100. package/package.json +10 -14
  101. package/src/client/addon/controller.ts +1 -1
  102. package/src/client/addon/withCreevey.ts +2 -16
  103. package/src/client/shared/components/PageHeader/PageHeader.tsx +18 -4
  104. package/src/client/shared/creeveyClientApi.ts +10 -0
  105. package/src/client/web/CreeveyApp.tsx +2 -0
  106. package/src/client/web/CreeveyContext.tsx +2 -0
  107. package/src/client/web/CreeveyView/SideBar/SideBarFooter.tsx +19 -17
  108. package/src/client/web/CreeveyView/SideBar/SideBarHeader.tsx +18 -3
  109. package/src/client/web/CreeveyView/SideBar/SuiteLink.tsx +9 -7
  110. package/src/client/web/CreeveyView/SideBar/TestLink.tsx +8 -6
  111. package/src/client/web/CreeveyView/SideBar/TestsStatus.tsx +1 -0
  112. package/src/client/web/index.tsx +8 -3
  113. package/src/creevey.ts +1 -24
  114. package/src/playwright-reporter.ts +3 -0
  115. package/src/server/config.ts +9 -1
  116. package/src/server/index.ts +13 -6
  117. package/src/server/master/api.ts +94 -28
  118. package/src/server/master/handlers/capture-handler.ts +20 -0
  119. package/src/server/master/handlers/index.ts +4 -0
  120. package/src/server/master/handlers/ping-handler.ts +6 -0
  121. package/src/server/master/handlers/static-handler.ts +18 -0
  122. package/src/server/master/handlers/stories-handler.ts +20 -0
  123. package/src/server/master/master.ts +10 -27
  124. package/src/server/master/runner.ts +38 -132
  125. package/src/server/master/server.ts +210 -98
  126. package/src/server/master/start.ts +17 -41
  127. package/src/server/master/testsManager.ts +315 -0
  128. package/src/server/playwright/docker-file.ts +2 -2
  129. package/src/server/playwright/reporter.ts +386 -0
  130. package/src/server/selenium/internal.ts +23 -3
  131. package/src/server/selenium/selenoid.ts +5 -0
  132. package/src/server/shutdown.ts +19 -0
  133. package/src/server/stories.ts +1 -12
  134. package/src/server/ui-update.ts +46 -0
  135. package/src/server/utils.ts +40 -9
  136. package/src/server/worker/start.ts +1 -1
  137. package/src/types.ts +14 -8
@@ -1,44 +1,27 @@
1
- import path from 'path';
2
- import { Config, TestData, isDefined, ServerTest } from '../../types.js';
3
- import { loadTestsFromStories, saveTestsJson } from '../stories.js';
1
+ import { Config } from '../../types.js';
2
+ import { loadTestsFromStories } from '../stories.js';
4
3
  import Runner from './runner.js';
5
- import { tryToLoadTestsData } from '../utils.js';
6
-
7
- function mergeTests(
8
- testsWithReports: Partial<Record<string, TestData>>,
9
- testsFromStories: Partial<Record<string, ServerTest>>,
10
- ): Partial<Record<string, ServerTest>> {
11
- Object.values(testsFromStories)
12
- .filter(isDefined)
13
- .forEach((test) => {
14
- const testWithReport = testsWithReports[test.id];
15
- if (!testWithReport) return;
16
- test.retries = testWithReport.retries;
17
- if (testWithReport.status == 'success' || testWithReport.status == 'failed') test.status = testWithReport.status;
18
- test.results = testWithReport.results;
19
- test.approved = testWithReport.approved;
20
- });
21
- return testsFromStories;
22
- }
4
+ import { TestsManager } from './testsManager.js';
23
5
 
24
6
  export default async function master(config: Config, gridUrl?: string): Promise<Runner> {
25
- const runner = new Runner(config, gridUrl);
26
- const reportDataPath = path.join(config.reportDir, 'data.js');
27
- const testsFromReport = tryToLoadTestsData(reportDataPath) ?? {};
7
+ // Create TestsManager instance
8
+ const testsManager = new TestsManager(config.screenDir, config.reportDir);
9
+
10
+ // Create Runner with TestsManager
11
+ const runner = new Runner(config, testsManager, gridUrl);
28
12
 
29
13
  await runner.init();
30
14
 
15
+ // Load tests from stories and update TestsManager
31
16
  const tests = await loadTestsFromStories(
32
17
  Object.keys(config.browsers),
33
18
  (listener) => config.storiesProvider(config, listener),
34
19
  (testsDiff) => {
35
20
  runner.updateTests(testsDiff);
36
- saveTestsJson(runner.tests, config.reportDir);
37
21
  },
38
22
  );
39
23
 
40
- runner.tests = mergeTests(testsFromReport, tests);
41
- saveTestsJson(runner.tests, config.reportDir);
24
+ testsManager.updateTests(testsManager.loadAndMergeTests(tests));
42
25
 
43
26
  return runner;
44
27
  }
@@ -1,5 +1,3 @@
1
- import path from 'path';
2
- import { copyFile, mkdir } from 'fs/promises';
3
1
  import { EventEmitter } from 'events';
4
2
  import {
5
3
  Config,
@@ -10,7 +8,6 @@ import {
10
8
  CreeveyUpdate,
11
9
  TestStatus,
12
10
  ServerTest,
13
- TestMeta,
14
11
  TEST_EVENTS,
15
12
  FakeSuite,
16
13
  FakeTest,
@@ -19,6 +16,7 @@ import Pool from './pool.js';
19
16
  import { WorkerQueue } from './queue.js';
20
17
  import { getTestPath } from '../utils.js';
21
18
  import { getReporter } from '../reporters/index.js';
19
+ import { TestsManager } from './testsManager.js';
22
20
 
23
21
  // NOTE: This is workaround to fix parallel tests running with mocha-junit-reporter
24
22
  let isJUnit = false;
@@ -33,24 +31,23 @@ class FakeRunner extends EventEmitter {
33
31
 
34
32
  export default class Runner extends EventEmitter {
35
33
  private failFast: boolean;
36
- private screenDir: string;
37
- private reportDir: string;
38
34
  private browsers: string[];
39
35
  private scheduler: WorkerQueue;
40
36
  private pools: Record<string, Pool> = {};
41
37
  private fakeRunner: FakeRunner;
42
38
  private config: Config;
43
- tests: Partial<Record<string, ServerTest>> = {};
39
+ public testsManager: TestsManager;
40
+
44
41
  public get isRunning(): boolean {
45
42
  return Object.values(this.pools).some((pool) => pool.isRunning);
46
43
  }
47
- constructor(config: Config, gridUrl?: string) {
44
+
45
+ constructor(config: Config, testsManager: TestsManager, gridUrl?: string) {
48
46
  super();
49
47
 
50
48
  this.config = config;
51
49
  this.failFast = config.failFast;
52
- this.screenDir = config.screenDir;
53
- this.reportDir = config.reportDir;
50
+ this.testsManager = testsManager;
54
51
  this.scheduler = new WorkerQueue(config.useWorkerQueue);
55
52
  this.browsers = Object.keys(config.browsers);
56
53
 
@@ -61,7 +58,7 @@ export default class Runner extends EventEmitter {
61
58
  isJUnit = true;
62
59
  }
63
60
 
64
- new Reporter(runner, { reportDir: this.reportDir, reporterOptions: config.reporterOptions });
61
+ new Reporter(runner, { reportDir: config.reportDir, reporterOptions: config.reporterOptions });
65
62
  this.fakeRunner = runner;
66
63
 
67
64
  this.browsers
@@ -71,10 +68,10 @@ export default class Runner extends EventEmitter {
71
68
 
72
69
  private handlePoolMessage = (message: { id: string; status: TestStatus; result?: TestResult }): void => {
73
70
  const { id, status, result } = message;
74
- const test = this.tests[id];
71
+ const test = this.testsManager.getTest(id);
75
72
 
76
73
  if (!test) return;
77
- const { browser, testName, storyPath, storyId } = test;
74
+ const { browser, testName } = test;
78
75
 
79
76
  const fakeSuite: FakeSuite = {
80
77
  title: test.storyPath.slice(0, -1).join('/'),
@@ -103,20 +100,14 @@ export default class Runner extends EventEmitter {
103
100
 
104
101
  fakeSuite.tests.push(fakeTest);
105
102
 
106
- // TODO Handle 'retrying' status
107
- test.status = status == 'retrying' ? 'failed' : status;
103
+ const update = this.testsManager.updateTestStatus(id, status, result);
104
+ if (!update) return;
105
+
108
106
  if (!result) {
109
- // NOTE: Running status
110
107
  this.fakeRunner.emit(TEST_EVENTS.TEST_BEGIN, fakeTest);
111
- this.sendUpdate({ tests: { [id]: { id, browser, testName, storyPath, status: test.status, storyId } } });
108
+ this.sendUpdate(update);
112
109
  return;
113
110
  }
114
- test.results ??= [];
115
- test.results.push(result);
116
-
117
- if (status == 'failed') {
118
- test.approved = null;
119
- }
120
111
 
121
112
  const { duration, attachments } = result;
122
113
 
@@ -146,20 +137,7 @@ export default class Runner extends EventEmitter {
146
137
 
147
138
  this.fakeRunner.emit(TEST_EVENTS.TEST_END, fakeTest);
148
139
 
149
- this.sendUpdate({
150
- tests: {
151
- [id]: {
152
- id,
153
- browser,
154
- testName,
155
- storyPath,
156
- status: test.status,
157
- approved: test.approved,
158
- results: [result],
159
- storyId,
160
- },
161
- },
162
- });
140
+ this.sendUpdate(update);
163
141
 
164
142
  if (this.failFast && status == 'failed') this.stop();
165
143
  };
@@ -177,31 +155,8 @@ export default class Runner extends EventEmitter {
177
155
  }
178
156
 
179
157
  public updateTests(testsDiff: Partial<Record<string, ServerTest>>): void {
180
- const tests: CreeveyStatus['tests'] = {};
181
- const removedTests: TestMeta[] = [];
182
- Object.entries(testsDiff).forEach(([id, newTest]) => {
183
- const oldTest = this.tests[id];
184
- if (newTest) {
185
- if (oldTest) {
186
- this.tests[id] = {
187
- ...newTest,
188
- retries: oldTest.retries,
189
- results: oldTest.results,
190
- approved: oldTest.approved,
191
- };
192
- } else this.tests[id] = newTest;
193
-
194
- const { story: _, fn: __, ...restTest } = newTest;
195
- tests[id] = { ...restTest, status: 'unknown' };
196
- } else if (oldTest) {
197
- const { id, browser, testName, storyPath, storyId } = oldTest;
198
- removedTests.push({ id, browser, testName, storyPath, storyId });
199
- // TODO Use Map instead
200
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
201
- delete this.tests[id];
202
- }
203
- });
204
- this.sendUpdate({ tests, removedTests });
158
+ const update = this.testsManager.updateTests(testsDiff);
159
+ if (update) this.sendUpdate(update);
205
160
  }
206
161
 
207
162
  public start(ids: string[]): void {
@@ -209,27 +164,32 @@ export default class Runner extends EventEmitter {
209
164
  if (this.isRunning) return;
210
165
 
211
166
  const testsToStart = ids
212
- .map((id) => this.tests[id])
167
+ .map((id) => this.testsManager.getTest(id))
213
168
  .filter(isDefined)
214
169
  .filter((test) => !test.skip);
215
170
 
216
171
  if (testsToStart.length == 0) return;
217
172
 
173
+ const pendingTests: CreeveyUpdate['tests'] = testsToStart.reduce(
174
+ (update: CreeveyUpdate['tests'], { id, storyId, browser, testName, storyPath }) => ({
175
+ ...update,
176
+ [id]: { id, browser, testName, storyPath, status: 'pending', storyId },
177
+ }),
178
+ {},
179
+ );
180
+
218
181
  this.sendUpdate({
219
182
  isRunning: true,
220
- tests: testsToStart.reduce(
221
- (update: CreeveyUpdate['tests'], { id, storyId, browser, testName, storyPath }) => ({
222
- ...update,
223
- [id]: { id, browser, testName, storyPath, status: 'pending', storyId },
224
- }),
225
- {},
226
- ),
183
+ tests: pendingTests,
227
184
  });
228
185
 
229
186
  const testsByBrowser: Partial<TestsByBrowser> = testsToStart.reduce((tests: Partial<TestsByBrowser>, test) => {
230
187
  const { id, browser, testName, storyPath } = test;
231
188
  const restPath = [...storyPath, testName].filter(isDefined);
232
- test.status = 'pending';
189
+
190
+ // Update status to pending in TestsManager
191
+ this.testsManager.updateTestStatus(id, 'pending');
192
+
233
193
  return {
234
194
  ...tests,
235
195
  [browser]: [...(tests[browser] ?? []), { id, path: restPath }],
@@ -255,78 +215,24 @@ export default class Runner extends EventEmitter {
255
215
  }
256
216
 
257
217
  public get status(): CreeveyStatus {
258
- const tests: CreeveyStatus['tests'] = {};
259
- Object.values(this.tests)
260
- .filter(isDefined)
261
-
262
- .forEach(({ story: _, fn: __, ...test }) => (tests[test.id] = test));
263
218
  return {
264
219
  isRunning: this.isRunning,
265
- tests,
220
+ tests: this.testsManager.getTestsData(),
266
221
  browsers: this.browsers,
222
+ isUpdateMode: false,
267
223
  };
268
224
  }
269
225
 
270
- private async copyImage(test: ServerTest, image: string, actual: string): Promise<void> {
271
- const { browser, testName, storyPath } = test;
272
- const restPath = [...storyPath, testName].filter(isDefined);
273
- const testPath = path.join(...restPath, image == browser ? '' : browser);
274
- const srcImagePath = path.join(this.reportDir, testPath, actual);
275
- const dstImagePath = path.join(this.screenDir, testPath, `${image}.png`);
276
- await mkdir(path.join(this.screenDir, testPath), { recursive: true });
277
- await copyFile(srcImagePath, dstImagePath);
278
- }
279
-
280
226
  public async approveAll(): Promise<void> {
281
- const updatedTests: NonNullable<CreeveyUpdate['tests']> = {};
282
- for (const test of Object.values(this.tests)) {
283
- if (!test?.results) continue;
284
- const retry = test.results.length - 1;
285
- const { images, status } = test.results.at(retry) ?? {};
286
- if (!images || status != 'failed') continue;
287
- for (const [name, image] of Object.entries(images)) {
288
- if (!image) continue;
289
- await this.copyImage(test, name, image.actual);
290
-
291
- test.approved ??= {};
292
- test.approved[name] = retry;
293
- test.status = 'approved';
294
-
295
- updatedTests[test.id] = {
296
- id: test.id,
297
- browser: test.browser,
298
- storyPath: test.storyPath,
299
- storyId: test.storyId,
300
- status: test.status,
301
- approved: { [name]: retry },
302
- };
303
- }
304
- }
305
- this.sendUpdate({ tests: updatedTests });
227
+ const update = await this.testsManager.approveAll();
228
+ this.sendUpdate(update);
306
229
  }
307
230
 
308
- public async approve({ id, retry, image }: ApprovePayload): Promise<void> {
309
- const test = this.tests[id];
310
- if (!test?.results) return;
311
- const result = test.results[retry];
312
- if (!result.images) return;
313
- const images = result.images[image];
314
- if (!images) return;
315
- test.approved ??= {};
316
- const { browser, testName, storyPath, storyId } = test;
317
-
318
- await this.copyImage(test, image, images.actual);
319
-
320
- test.approved[image] = retry;
321
-
322
- if (Object.keys(result.images).every((name) => typeof test.approved?.[name] == 'number')) {
323
- test.status = 'approved';
324
- }
325
-
326
- this.sendUpdate({
327
- tests: { [id]: { id, browser, testName, storyPath, status: test.status, approved: { [image]: retry }, storyId } },
328
- });
231
+ public async approve(payload: ApprovePayload): Promise<void> {
232
+ const update = await this.testsManager.approve(payload);
233
+ if (update) this.sendUpdate(update);
329
234
  }
235
+
330
236
  private sendUpdate(data: CreeveyUpdate): void {
331
237
  this.emit('update', data);
332
238
  }
@@ -1,125 +1,237 @@
1
+ import fs from 'fs';
2
+ import url from 'url';
1
3
  import path from 'path';
2
- import http from 'http';
3
- import cluster from 'cluster';
4
- import Koa from 'koa';
5
- import cors from '@koa/cors';
6
- import serve from 'koa-static';
7
- import mount from 'koa-mount';
8
- import body from 'koa-bodyparser';
9
- import WebSocket from 'ws';
4
+ import { IncomingMessage, ServerResponse, createServer } from 'http';
5
+ import { WebSocketServer, WebSocket, RawData } from 'ws';
10
6
  import { fileURLToPath, pathToFileURL } from 'url';
11
- import { CreeveyApi } from './api.js';
12
- import { emitStoriesMessage, sendStoriesMessage, subscribeOn, subscribeOnWorker } from '../messages.js';
13
- import { CaptureOptions, isDefined, noop, StoryInput } from '../../types.js';
7
+ import { shutdownOnException } from '../utils.js';
8
+ import { subscribeOn } from '../messages.js';
9
+ import { noop } from '../../types.js';
14
10
  import { logger } from '../logger.js';
15
- import { deserializeStory } from '../../shared/index.js';
11
+ import { CreeveyApi } from './api.js';
12
+ import { pingHandler, captureHandler, storiesHandler, staticHandler } from './handlers/index.js';
13
+
14
+ function json<T = unknown>(
15
+ handler: (data: T) => void,
16
+ defaultValue: T,
17
+ ): (request: IncomingMessage, response: ServerResponse) => void {
18
+ return (request: IncomingMessage, response: ServerResponse) => {
19
+ const chunks: Buffer[] = [];
20
+
21
+ request.on('data', (chunk: Buffer) => {
22
+ chunks.push(chunk);
23
+ });
24
+
25
+ request.on('end', () => {
26
+ try {
27
+ const body = Buffer.concat(chunks);
28
+ const value = body.length === 0 ? defaultValue : (JSON.parse(body.toString('utf-8')) as T);
29
+
30
+ handler(value);
31
+ response.end();
32
+ } catch (error) {
33
+ logger().error('Failed to parse JSON', error);
34
+ const errorMessage = error instanceof Error ? error.message : String(error);
35
+ response.statusCode = 500;
36
+ response.setHeader('Content-Type', 'text/plain');
37
+ response.end(`Failed to parse JSON: ${errorMessage}`);
38
+ }
39
+ });
40
+
41
+ request.on('error', (error) => {
42
+ logger().error('Failed to parse JSON', error);
43
+ const errorMessage = error instanceof Error ? error.message : String(error);
44
+ response.statusCode = 500;
45
+ response.setHeader('Content-Type', 'text/plain');
46
+ response.end(`Failed to parse JSON: ${errorMessage}`);
47
+ });
48
+ };
49
+ }
50
+
51
+ function file(handler: (requestedPath: string) => string | undefined) {
52
+ return (request: IncomingMessage, response: ServerResponse) => {
53
+ const parsedUrl = url.parse(request.url ?? '/', true);
54
+ const requestedPath = parsedUrl.pathname ?? '/';
55
+
56
+ try {
57
+ const filePath = handler(requestedPath);
58
+ if (filePath) {
59
+ const stat = fs.statSync(filePath);
60
+ // Set appropriate MIME type
61
+ const ext = path.extname(filePath).toLowerCase();
62
+ const mimeTypes: Record<string, string> = {
63
+ '.html': 'text/html',
64
+ '.js': 'application/javascript',
65
+ '.css': 'text/css',
66
+ '.json': 'application/json',
67
+ '.png': 'image/png',
68
+ '.jpg': 'image/jpeg',
69
+ '.jpeg': 'image/jpeg',
70
+ '.gif': 'image/gif',
71
+ '.svg': 'image/svg+xml',
72
+ '.ico': 'image/x-icon',
73
+ };
74
+
75
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
76
+
77
+ response.statusCode = 200;
78
+ response.setHeader('Content-Type', contentType);
79
+ response.setHeader('Content-Length', stat.size);
80
+
81
+ // Stream the file
82
+ const stream = fs.createReadStream(filePath);
83
+ stream.pipe(response);
84
+
85
+ stream.on('error', (error) => {
86
+ logger().error('Error streaming file', error);
87
+ if (!response.headersSent) {
88
+ const errorMessage = error instanceof Error ? error.message : String(error);
89
+ response.statusCode = 500;
90
+ response.setHeader('Content-Type', 'text/plain');
91
+ response.end(`Internal server error: ${errorMessage}`);
92
+ }
93
+ });
94
+ } else {
95
+ logger().error('File not found', requestedPath);
96
+ response.statusCode = 404;
97
+ response.setHeader('Content-Type', 'text/plain');
98
+ response.end('File not found');
99
+ }
100
+ } catch (error) {
101
+ logger().error('Failed to serve file', error);
102
+ const errorMessage = error instanceof Error ? error.message : String(error);
103
+ response.statusCode = 500;
104
+ response.setHeader('Content-Type', 'text/plain');
105
+ response.end(`Failed to serve file: ${errorMessage}`);
106
+ }
107
+ };
108
+ }
16
109
 
17
110
  const importMetaUrl = pathToFileURL(__filename).href;
18
111
 
19
112
  export function start(reportDir: string, port: number, ui: boolean): (api: CreeveyApi) => void {
113
+ let wss: WebSocketServer | null = null;
114
+ let creeveyApi: CreeveyApi | null = null;
20
115
  let resolveApi: (api: CreeveyApi) => void = noop;
21
- let setStoriesCounter = 0;
22
- const creeveyApi = new Promise<CreeveyApi>((resolve) => (resolveApi = resolve));
23
- const app = new Koa();
24
- // eslint-disable-next-line @typescript-eslint/no-misused-promises
25
- const server = http.createServer(app.callback());
26
- const wss = new WebSocket.Server({ server });
27
-
28
- app.use(cors());
29
- app.use(body());
30
-
31
- app.use(async (ctx, next) => {
32
- if (ctx.method == 'GET' && ctx.path == '/ping') {
33
- ctx.body = 'pong';
34
- return;
116
+
117
+ const webDir = path.join(path.dirname(fileURLToPath(importMetaUrl)), '../../client/web');
118
+ const server = createServer();
119
+
120
+ const routes = [
121
+ {
122
+ path: '/ping',
123
+ method: 'GET',
124
+ handler: pingHandler,
125
+ },
126
+ {
127
+ path: '/stories',
128
+ method: 'POST',
129
+ handler: json(storiesHandler, { stories: [] }),
130
+ },
131
+ {
132
+ path: '/capture',
133
+ method: 'POST',
134
+ handler: json(captureHandler, { workerId: 0, options: undefined }),
135
+ },
136
+ {
137
+ path: '/report/',
138
+ method: 'GET',
139
+ handler: file(staticHandler(reportDir, '/report/')),
140
+ },
141
+ {
142
+ path: '/',
143
+ method: 'GET',
144
+ handler: file(staticHandler(webDir)),
145
+ },
146
+ ];
147
+
148
+ const router = (request: IncomingMessage, response: ServerResponse): void => {
149
+ const parsedUrl = url.parse(request.url ?? '/', true);
150
+ const path = parsedUrl.pathname ?? '/';
151
+ const method = request.method ?? 'GET';
152
+
153
+ try {
154
+ const route = routes.find((route) => path.startsWith(route.path) && route.method === method);
155
+ if (route) {
156
+ route.handler(request, response);
157
+ } else {
158
+ response.statusCode = 404;
159
+ response.setHeader('Content-Type', 'text/plain');
160
+ response.end('Not Found');
161
+ }
162
+ } catch (error) {
163
+ logger().error('Request handling error', error);
164
+ response.statusCode = 500;
165
+ response.setHeader('Content-Type', 'text/plain');
166
+ response.end('Internal Server Error');
35
167
  }
36
- await next();
37
- });
168
+ };
38
169
 
39
- if (ui) {
40
- app.use(async (_, next) => {
41
- await creeveyApi;
42
- await next();
43
- });
44
- }
170
+ server.on('request', (request: IncomingMessage, response: ServerResponse): void => {
171
+ response.setHeader('Access-Control-Allow-Origin', '*');
172
+ response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
173
+ response.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
45
174
 
46
- app.use(async (ctx, next) => {
47
- if (ctx.method == 'POST' && ctx.path == '/stories') {
48
- const { setStoriesCounter: counter, stories } = ctx.request.body as {
49
- setStoriesCounter: number;
50
- stories: [string, StoryInput[]][];
51
- };
52
- if (setStoriesCounter >= counter) return;
53
-
54
- const deserializedStories = stories.map<[string, StoryInput[]]>(([file, stories]) => [
55
- file,
56
- stories.map(deserializeStory),
57
- ]);
58
-
59
- setStoriesCounter = counter;
60
- emitStoriesMessage({ type: 'update', payload: deserializedStories });
61
- Object.values(cluster.workers ?? {})
62
- .filter(isDefined)
63
- .filter((worker) => worker.isConnected())
64
- .forEach((worker) => {
65
- sendStoriesMessage(worker, { type: 'update', payload: deserializedStories });
66
- });
175
+ if (request.method === 'OPTIONS') {
176
+ response.statusCode = 200;
177
+ response.end();
67
178
  return;
68
179
  }
69
- await next();
70
- });
71
180
 
72
- app.use(async (ctx, next) => {
73
- if (ctx.method == 'POST' && ctx.path == '/capture') {
74
- const { workerId, options } = ctx.request.body as { workerId: number; options?: CaptureOptions };
75
- const worker = Object.values(cluster.workers ?? {})
76
- .filter(isDefined)
77
- .find((worker) => worker.process.pid == workerId);
78
- // NOTE: Hypothetical case when someone send to us capture req and we don't have a worker with browser session for it
79
- if (!worker) return;
80
- await new Promise<void>((resolve) => {
81
- const unsubscribe = subscribeOnWorker(worker, 'stories', (message) => {
82
- if (message.type != 'capture') return;
83
- unsubscribe();
84
- resolve();
85
- });
86
- sendStoriesMessage(worker, { type: 'capture', payload: options });
87
- });
88
- // TODO Pass screenshot result to show it in inspector
89
- ctx.body = 'Ok';
90
- return;
91
- }
92
- await next();
181
+ router(request, response);
93
182
  });
94
183
 
95
- app.use(serve(path.join(path.dirname(fileURLToPath(importMetaUrl)), '../../client/web')));
96
- app.use(mount('/report', serve(reportDir)));
184
+ if (ui) {
185
+ wss = new WebSocketServer({ server });
186
+ wss.on('connection', (ws: WebSocket) => {
187
+ ws.on('message', (message: RawData, isBinary: boolean) => {
188
+ if (creeveyApi) {
189
+ // NOTE Text messages are passed as Buffer https://github.com/websockets/ws/releases/tag/8.0.0
190
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
191
+ creeveyApi.handleMessage(ws, isBinary ? message : message.toString('utf-8'));
192
+ return;
193
+ }
194
+ });
97
195
 
98
- wss.on('error', (error) => {
99
- logger().error(error);
100
- });
196
+ ws.on('error', (error) => {
197
+ logger().error('WebSocket error', error);
198
+ });
199
+ });
101
200
 
102
- server.listen(port);
201
+ wss.on('error', (error) => {
202
+ logger().error('WebSocket error', error);
203
+ });
204
+ }
103
205
 
104
206
  subscribeOn('shutdown', () => {
207
+ if (wss) {
208
+ wss.clients.forEach((ws) => {
209
+ ws.close();
210
+ });
211
+ wss.close();
212
+ }
213
+
105
214
  server.close();
106
- wss.close();
107
- wss.clients.forEach((ws) => {
108
- ws.close();
109
- });
110
215
  });
111
216
 
112
- void creeveyApi.then((api) => {
113
- api.subscribe(wss);
114
-
115
- wss.on('connection', (ws) => {
116
- ws.on('message', (message: WebSocket.RawData, isBinary: boolean) => {
117
- // NOTE Text messages are passed as Buffer https://github.com/websockets/ws/releases/tag/8.0.0
118
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
119
- api.handleMessage(ws, isBinary ? message : message.toString('utf-8'));
120
- });
217
+ server
218
+ .listen(port, () => {
219
+ logger().info(`Server starting on port ${port}`);
220
+ })
221
+ .on('error', (error: unknown) => {
222
+ logger().error('Failed to start server', error);
223
+ process.exit(1);
121
224
  });
122
- });
123
225
 
226
+ void new Promise<CreeveyApi>((resolve) => (resolveApi = resolve))
227
+ .then((api) => {
228
+ creeveyApi = api;
229
+ if (wss) {
230
+ creeveyApi.subscribe(wss);
231
+ }
232
+ })
233
+ .catch(shutdownOnException);
234
+
235
+ // Return the function to resolve the API
124
236
  return resolveApi;
125
237
  }