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
@@ -0,0 +1,386 @@
1
+ import type { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep } from '@playwright/test/reporter';
2
+ import path from 'path';
3
+ import fs from 'fs/promises';
4
+ import { TestsManager } from '../master/testsManager.js';
5
+ import { CreeveyApi } from '../master/api.js';
6
+ import { ServerTest, TestMeta, TestStatus, TestResult as CreeveyTestResult } from '../../types.js';
7
+ import { copyStatics } from '../utils.js';
8
+
9
+ /**
10
+ * Simple async queue to handle operations in sequence without returning promises
11
+ * from reporter methods that should be synchronous
12
+ */
13
+ class AsyncQueue {
14
+ private queue: Promise<void>;
15
+
16
+ constructor() {
17
+ this.queue = Promise.resolve();
18
+ }
19
+
20
+ /**
21
+ * Add an async operation to the queue
22
+ * @param operation Async operation to execute
23
+ */
24
+ enqueue(operation: () => Promise<void>): void {
25
+ this.queue = this.queue.then(operation).catch((error: unknown) => {
26
+ console.error(`Error in async queue: ${error instanceof Error ? error.message : String(error)}`);
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Wait for all operations in the queue to complete
32
+ */
33
+ async waitForCompletion(): Promise<void> {
34
+ await this.queue;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * CreeveyPlaywrightReporter is a Playwright reporter that integrates with Creevey
40
+ * to provide visual testing capabilities and use Creevey's UI for reviewing and approving screenshots.
41
+ */
42
+ export class CreeveyPlaywrightReporter implements Reporter {
43
+ private testsManager: TestsManager;
44
+ private api: CreeveyApi | null = null;
45
+ private reportDir: string;
46
+ private screenDir: string;
47
+ private port: number;
48
+ private debug: boolean;
49
+ private startServer: ((reportDir: string, port: number, uiEnabled: boolean) => (api: CreeveyApi) => void) | null =
50
+ null;
51
+ private testIdMap = new Map<string, string>(); // Maps Playwright test IDs to Creevey test IDs
52
+ private asyncQueue = new AsyncQueue();
53
+
54
+ /**
55
+ * Creates a new instance of the CreeveyPlaywrightReporter
56
+ * @param options Configuration options for the reporter
57
+ */
58
+ constructor(options?: { reportDir?: string; screenDir?: string; port?: number; debug?: boolean }) {
59
+ this.reportDir = options?.reportDir ?? path.join(process.cwd(), 'report');
60
+ this.screenDir = options?.screenDir ?? path.join(process.cwd(), 'images');
61
+ this.port = options?.port ?? 3000;
62
+ this.debug = options?.debug ?? false;
63
+
64
+ // Initialize TestsManager
65
+ this.testsManager = new TestsManager(this.screenDir, this.reportDir);
66
+ }
67
+
68
+ /**
69
+ * Called when the test run starts
70
+ * @param config Playwright configuration
71
+ * @param suite Test suite information
72
+ */
73
+ onBegin(_config: FullConfig, _suite: Suite): void {
74
+ this.logDebug('CreeveyPlaywrightReporter started');
75
+
76
+ // Use the async queue to handle initialization without returning a promise
77
+ this.asyncQueue.enqueue(async () => {
78
+ try {
79
+ // Dynamically import the modules to avoid circular dependencies
80
+ const { start } = await import('../master/server.js');
81
+ this.startServer = start;
82
+
83
+ // Initialize report directory
84
+ try {
85
+ await fs.mkdir(this.reportDir, { recursive: true });
86
+ await copyStatics(this.reportDir);
87
+ } catch (error) {
88
+ this.logError(
89
+ `Failed to initialize report directory: ${error instanceof Error ? error.message : String(error)}`,
90
+ );
91
+ }
92
+
93
+ // Start server API
94
+ try {
95
+ const resolveApi = this.startServer(this.reportDir, this.port, true);
96
+
97
+ // Create and connect the API
98
+ this.api = new CreeveyApi(this.testsManager);
99
+ resolveApi(this.api);
100
+
101
+ console.log(`Creevey report server started at http://localhost:${this.port}`);
102
+ } catch (error) {
103
+ this.logError(`Could not start Creevey server: ${error instanceof Error ? error.message : String(error)}`);
104
+ console.log('Screenshots will still be captured but UI will not be available');
105
+ }
106
+ } catch (error) {
107
+ this.logError(
108
+ `Error in Creevey reporter initialization: ${error instanceof Error ? error.message : String(error)}`,
109
+ );
110
+ }
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Called when a test begins
116
+ * @param test Test case information
117
+ * @param result Test result (initially empty)
118
+ */
119
+ onTestBegin(test: TestCase, _result: TestResult): void {
120
+ try {
121
+ // Map test to Creevey test format
122
+ const creeveyTest = this.mapToCreeveyTest(test);
123
+ if (!creeveyTest) return;
124
+
125
+ // Create a mapping from Playwright test ID to Creevey test ID
126
+ this.testIdMap.set(test.id, creeveyTest.id);
127
+
128
+ // Update test status to running
129
+ this.testsManager.updateTestStatus(creeveyTest.id, 'running');
130
+
131
+ this.logDebug(`Test started: ${test.title} (${creeveyTest.id})`);
132
+ } catch (error) {
133
+ this.logError(`Error in onTestBegin: ${error instanceof Error ? error.message : String(error)}`);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Called when a test step begins
139
+ * @param test Test case information
140
+ * @param result Test result
141
+ * @param step Test step information
142
+ */
143
+ onStepBegin(test: TestCase, _result: TestResult, step: TestStep): void {
144
+ if (this.debug) {
145
+ this.logDebug(`Step started: ${step.title} in test: ${test.title}`);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Called when a test step ends
151
+ * @param test Test case information
152
+ * @param result Test result
153
+ * @param step Test step information
154
+ */
155
+ onStepEnd(_test: TestCase, _result: TestResult, step: TestStep): void {
156
+ try {
157
+ // If step has attachments, process them
158
+ if (step.attachments.length > 0) {
159
+ this.logDebug(`Processing ${step.attachments.length} attachments from step: ${step.title}`);
160
+
161
+ // We'll process attachments in onTestEnd for simplicity in this initial implementation
162
+ }
163
+ } catch (error) {
164
+ this.logError(`Error in onStepEnd: ${error instanceof Error ? error.message : String(error)}`);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Called when a test ends
170
+ * @param test Test case information
171
+ * @param result Test result
172
+ */
173
+ onTestEnd(test: TestCase, result: TestResult): void {
174
+ const creeveyTestId = this.testIdMap.get(test.id);
175
+
176
+ // Use the async queue to handle result processing without returning a promise
177
+ this.asyncQueue.enqueue(async () => {
178
+ try {
179
+ // Process test results and screenshots
180
+ await this.processTestResult(test, result);
181
+
182
+ if (creeveyTestId) {
183
+ this.logDebug(`Test ended: ${test.title} (${creeveyTestId}) with status: ${result.status}`);
184
+ }
185
+ } catch (error) {
186
+ this.logError(`Error in onTestEnd: ${error instanceof Error ? error.message : String(error)}`);
187
+ }
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Called when the test run ends
193
+ * @param result The overall test run result
194
+ */
195
+ onEnd(result: { status: 'passed' | 'failed' | 'timedout' | 'interrupted' }): void {
196
+ // Use the async queue to handle final operations without returning a promise
197
+ this.asyncQueue.enqueue(async () => {
198
+ try {
199
+ // Wait for all previous operations to complete
200
+ await this.asyncQueue.waitForCompletion();
201
+
202
+ // Save test data
203
+ await this.testsManager.saveTestData();
204
+
205
+ this.logDebug(`Test run ended with status: ${result.status}`);
206
+ console.log(`Visual test results available at http://localhost:${this.port}`);
207
+
208
+ // No cleanup of server here as it needs to stay running for the user to view results
209
+ } catch (error) {
210
+ this.logError(`Error during reporter cleanup: ${error instanceof Error ? error.message : String(error)}`);
211
+ }
212
+ });
213
+ }
214
+
215
+ /**
216
+ * Maps a Playwright test to a Creevey test format
217
+ * @param test Playwright test case
218
+ * @returns Creevey test object or null if mapping fails
219
+ */
220
+ private mapToCreeveyTest(test: TestCase): ServerTest | null {
221
+ try {
222
+ // Try to extract Creevey metadata from annotations
223
+ let testName = test.title;
224
+ let browser = 'chromium'; // Default browser
225
+ let storyPath: string[] = [];
226
+
227
+ const creeveyAnnotation = test.annotations.find((a) => a.type === 'creevey');
228
+ if (creeveyAnnotation?.description) {
229
+ try {
230
+ const metadata = JSON.parse(creeveyAnnotation.description) as {
231
+ testName?: string;
232
+ browser?: string;
233
+ storyPath?: string[];
234
+ };
235
+ if (metadata.testName) testName = metadata.testName;
236
+ if (metadata.browser) browser = metadata.browser;
237
+ if (metadata.storyPath) storyPath = metadata.storyPath;
238
+ } catch (e) {
239
+ this.logError(`Failed to parse Creevey metadata: ${e instanceof Error ? e.message : String(e)}`);
240
+ }
241
+ }
242
+
243
+ // If no explicit storyPath, use the project and file path
244
+ if (storyPath.length === 0) {
245
+ const projectName = test.parent.project()?.name;
246
+ const titlePath = test.titlePath().slice(0, -1); // Exclude the test title itself
247
+ storyPath = projectName ? [projectName, ...titlePath] : titlePath;
248
+ }
249
+
250
+ // Generate a unique test ID
251
+ const testId = `${storyPath.join('/')}/${testName}/${browser}`;
252
+
253
+ // Create the test metadata
254
+ const testMeta: TestMeta = {
255
+ id: testId,
256
+ storyPath,
257
+ browser,
258
+ testName,
259
+ storyId: storyPath.join('/'),
260
+ };
261
+
262
+ // Create a stub ServerTest object
263
+ // This is missing the story and fn properties which would be used in a real Creevey test
264
+ // However, for our reporter purposes, we just need the metadata
265
+ const serverTest: ServerTest = {
266
+ ...testMeta,
267
+ story: {
268
+ parameters: {},
269
+ initialArgs: {},
270
+ argTypes: {},
271
+ component: '',
272
+ componentId: '',
273
+ name: '',
274
+ tags: [],
275
+ title: '',
276
+ kind: '',
277
+ id: '',
278
+ story: '',
279
+ }, // Placeholder
280
+ fn: async () => {
281
+ /* Empty function as placeholder */
282
+ }, // Placeholder
283
+ };
284
+
285
+ return serverTest;
286
+ } catch (error) {
287
+ this.logError(`Error mapping test to Creevey format: ${error instanceof Error ? error.message : String(error)}`);
288
+ return null;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Process a test result and any attachments
294
+ * @param test Playwright test case
295
+ * @param result Playwright test result
296
+ */
297
+ private async processTestResult(test: TestCase, result: TestResult): Promise<void> {
298
+ const creeveyTestId = this.testIdMap.get(test.id);
299
+ if (!creeveyTestId) {
300
+ this.logError(`No Creevey test ID found for test: ${test.title}`);
301
+ return;
302
+ }
303
+
304
+ // Determine test status
305
+ let status: TestStatus;
306
+ switch (result.status) {
307
+ case 'passed':
308
+ status = 'success';
309
+ break;
310
+ case 'failed':
311
+ case 'timedOut':
312
+ status = 'failed';
313
+ break;
314
+ default:
315
+ status = 'unknown';
316
+ }
317
+
318
+ // Process attachments
319
+ const images: Record<string, { actual: string }> = {};
320
+ const attachmentPaths: string[] = [];
321
+
322
+ if (result.attachments.length > 0) {
323
+ await fs.mkdir(path.join(this.reportDir, creeveyTestId), { recursive: true });
324
+
325
+ for (const attachment of result.attachments) {
326
+ // Only process image attachments
327
+ if (!attachment.contentType.startsWith('image/')) continue;
328
+
329
+ try {
330
+ const imageName = attachment.name || `screenshot-${Date.now()}`;
331
+ const imagePath = path.join(creeveyTestId, `${imageName}.png`);
332
+ const fullImagePath = path.join(this.reportDir, imagePath);
333
+
334
+ // Ensure directory exists
335
+ await fs.mkdir(path.dirname(fullImagePath), { recursive: true });
336
+
337
+ // Handle either buffer or path-based attachments
338
+ if (attachment.body) {
339
+ await fs.writeFile(fullImagePath, attachment.body);
340
+ } else if (attachment.path) {
341
+ await fs.copyFile(attachment.path, fullImagePath);
342
+ }
343
+
344
+ // Add to images for the test result
345
+ images[imageName] = { actual: `${imageName}.png` };
346
+ attachmentPaths.push(imagePath);
347
+
348
+ this.logDebug(`Saved screenshot: ${imageName} for test: ${test.title}`);
349
+ } catch (error) {
350
+ this.logError(`Failed to process attachment: ${error instanceof Error ? error.message : String(error)}`);
351
+ }
352
+ }
353
+ }
354
+
355
+ // Update test status and result
356
+ const testResult: CreeveyTestResult = {
357
+ status: status === 'success' ? 'success' : 'failed',
358
+ retries: result.retry,
359
+ images,
360
+ error: result.error?.message ?? undefined,
361
+ duration: result.duration,
362
+ attachments: attachmentPaths,
363
+ browserName: test.parent.project()?.name ?? 'unknown',
364
+ };
365
+
366
+ this.testsManager.updateTestStatus(creeveyTestId, status, testResult);
367
+ }
368
+
369
+ /**
370
+ * Logs a debug message if debug mode is enabled
371
+ * @param message Message to log
372
+ */
373
+ private logDebug(message: string): void {
374
+ if (this.debug) {
375
+ console.log(`[Creevey Reporter] ${message}`);
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Logs an error message
381
+ * @param message Error message to log
382
+ */
383
+ private logError(message: string): void {
384
+ console.error(`[Creevey Reporter] ERROR: ${message}`);
385
+ }
386
+ }
@@ -373,12 +373,32 @@ export class InternalBrowser {
373
373
  }
374
374
 
375
375
  async loadStoriesFromBrowser(): Promise<StoriesRaw> {
376
- const stories = await this.#browser.executeAsyncScript<StoriesRaw | undefined>(function (
377
- callback: (stories: StoriesRaw | undefined) => void,
376
+ const result = await this.#browser.executeAsyncScript<
377
+ [error?: { message: string; stack?: string } | null, stories?: StoriesRaw]
378
+ >(function (
379
+ callback: (response: [error?: { message: string; stack?: string } | null, stories?: StoriesRaw]) => void,
378
380
  ) {
379
- void window.__CREEVEY_GET_STORIES__().then(callback);
381
+ window
382
+ .__CREEVEY_GET_STORIES__()
383
+ .then((stories) => {
384
+ callback([null, stories]);
385
+ })
386
+ .catch((error: unknown) => {
387
+ const errorInfo = {
388
+ message: error instanceof Error ? error.message : String(error),
389
+ stack: error instanceof Error ? error.stack : undefined,
390
+ };
391
+ callback([errorInfo]);
392
+ });
380
393
  });
381
394
 
395
+ const [error, stories] = result;
396
+
397
+ if (error) {
398
+ const errorObj = new Error(error.message);
399
+ if (error.stack) errorObj.stack = error.stack;
400
+ throw errorObj;
401
+ }
382
402
  if (!stories) throw new Error("Can't get stories, it seems creevey or storybook API isn't available");
383
403
 
384
404
  return stories;
@@ -7,6 +7,7 @@ import { Config, BrowserConfigObject } from '../../types.js';
7
7
  import { downloadBinary, getCreeveyCache, killTree } from '../utils.js';
8
8
  import { pullImages, runImage } from '../docker.js';
9
9
  import { subscribeOn } from '../messages.js';
10
+ import { removeWorkerContainer } from '../worker/context.js';
10
11
 
11
12
  async function createSelenoidConfig(
12
13
  browsers: BrowserConfigObject[],
@@ -147,5 +148,9 @@ export async function startSelenoidContainer(config: Config, debug: boolean): Pr
147
148
  },
148
149
  };
149
150
 
151
+ subscribeOn('shutdown', () => {
152
+ void removeWorkerContainer();
153
+ });
154
+
150
155
  return runImage(selenoidImage, ['-limit', String(limit)], selenoidOptions, debug);
151
156
  }
@@ -0,0 +1,19 @@
1
+ import cluster from 'cluster';
2
+ import { subscribeOn } from './messages.js';
3
+ import { shutdownOnException, isShuttingDown } from './utils.js';
4
+
5
+ if (cluster.isWorker) {
6
+ subscribeOn('shutdown', () => {
7
+ isShuttingDown.current = true;
8
+ });
9
+ }
10
+
11
+ process.on('uncaughtException', shutdownOnException);
12
+ process.on('unhandledRejection', shutdownOnException);
13
+ // TODO SIGINT Stuck with selenium
14
+ process.on('SIGINT', () => {
15
+ if (isShuttingDown.current) {
16
+ process.exit(-1);
17
+ }
18
+ isShuttingDown.current = true;
19
+ });
@@ -1,5 +1,3 @@
1
- import path from 'path';
2
- import { mkdirSync, writeFileSync } from 'fs';
3
1
  import { createHash } from 'crypto';
4
2
  import _ from 'lodash';
5
3
  import type {
@@ -12,7 +10,7 @@ import type {
12
10
  CreeveyTestFunction,
13
11
  CreeveyTestContext,
14
12
  } from '../types.js';
15
- import { isDefined, isFunction } from '../types.js';
13
+ import { isDefined } from '../types.js';
16
14
  import { shouldSkip } from './utils.js';
17
15
 
18
16
  function storyTestFabric(delay?: number, testFn?: CreeveyTestFunction) {
@@ -129,12 +127,3 @@ export async function loadTestsFromStories(
129
127
 
130
128
  return tests;
131
129
  }
132
-
133
- export function saveTestsJson(tests: Record<string, unknown>, dstPath: string = process.cwd()): void {
134
- mkdirSync(dstPath, { recursive: true });
135
- writeFileSync(
136
- path.join(dstPath, 'tests.json'),
137
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
138
- JSON.stringify(tests, (_, value) => (isFunction(value) ? value.toString() : value), 2),
139
- );
140
- }
@@ -0,0 +1,46 @@
1
+ import { Config } from '../types.js';
2
+ import { logger } from './logger.js';
3
+ import { TestsManager } from './master/testsManager.js';
4
+ import { start as startServer } from './master/server.js';
5
+ import { CreeveyApi } from './master/api.js';
6
+
7
+ /**
8
+ * UI Update Mode implementation.
9
+ * This mode allows users to review and approve screenshots from the browser interface.
10
+ * It combines the functionality of both --ui and --update flags.
11
+ *
12
+ * @param config Creevey configuration
13
+ * @param port Port to run the server on
14
+ */
15
+ export async function uiUpdate(config: Config, port: number): Promise<void> {
16
+ logger().info('Starting UI Update Mode');
17
+
18
+ // Initialize TestsManager with the configured directories
19
+ const testsManager = new TestsManager(config.screenDir, config.reportDir);
20
+
21
+ // Load tests from the report
22
+ const testsFromReport = testsManager.loadTestsFromReport();
23
+
24
+ if (Object.keys(testsFromReport).length === 0) {
25
+ logger().warn('No tests found in report. Run tests first to generate report data.');
26
+ return;
27
+ }
28
+
29
+ // Set tests in the manager
30
+ testsManager.updateTests(testsFromReport);
31
+
32
+ // Start API server with UI enabled
33
+ const resolveApi = startServer(config.reportDir, port, true);
34
+
35
+ // Initialize API
36
+ const api = new CreeveyApi(testsManager);
37
+
38
+ // Resolve the API for the server
39
+ resolveApi(api);
40
+
41
+ // Save test data to make it available for the UI
42
+ await testsManager.saveTestData();
43
+
44
+ logger().info(`UI Update Mode started on http://localhost:${port}/`);
45
+ logger().info('You can now review and approve screenshots from the browser.');
46
+ }
@@ -1,16 +1,17 @@
1
1
  import fs from 'fs';
2
- import https from 'https';
2
+ import path from 'path';
3
3
  import http from 'http';
4
+ import https from 'https';
5
+ import assert from 'assert';
4
6
  import cluster from 'cluster';
5
- import { dirname } from 'path';
7
+ import pidtree from 'pidtree';
6
8
  import { fileURLToPath, pathToFileURL } from 'url';
7
9
  import { register as esmRegister } from 'tsx/esm/api';
8
10
  import { register as cjsRegister } from 'tsx/cjs/api';
9
11
  import { SkipOptions, SkipOption, isDefined, TestData, noop, ServerTest, Worker } from '../types.js';
10
- import { emitShutdownMessage, sendShutdownMessage } from './messages.js';
12
+ import { emitShutdownMessage, emitWorkerMessage, sendShutdownMessage } from './messages.js';
11
13
  import { LOCALHOST_REGEXP } from './webdriver.js';
12
- import assert from 'assert';
13
- import pidtree from 'pidtree';
14
+ import { logger } from './logger.js';
14
15
 
15
16
  const importMetaUrl = pathToFileURL(__filename).href;
16
17
 
@@ -89,6 +90,18 @@ export function shouldSkipByOption(
89
90
  return skipByBrowser && skipByKind && skipByStory && skipByTest && reason;
90
91
  }
91
92
 
93
+ export function shutdownOnException(reason: unknown): void {
94
+ if (isShuttingDown.current) return;
95
+
96
+ const error = reason instanceof Error ? (reason.stack ?? reason.message) : (reason as string);
97
+
98
+ logger().error(error);
99
+
100
+ process.exitCode = -1;
101
+ if (cluster.isWorker) emitWorkerMessage({ type: 'error', payload: { subtype: 'unknown', error } });
102
+ if (cluster.isPrimary) void shutdownWorkers();
103
+ }
104
+
92
105
  export async function shutdownWorkers(): Promise<void> {
93
106
  isShuttingDown.current = true;
94
107
  await Promise.all(
@@ -151,7 +164,7 @@ export function resolvePlaywrightBrowserType(browserName: string): (typeof brows
151
164
 
152
165
  export async function getCreeveyCache(): Promise<string | undefined> {
153
166
  const { default: findCacheDir } = await import('find-cache-dir');
154
- return findCacheDir({ name: 'creevey', cwd: dirname(fileURLToPath(importMetaUrl)) });
167
+ return findCacheDir({ name: 'creevey', cwd: path.dirname(fileURLToPath(importMetaUrl)) });
155
168
  }
156
169
 
157
170
  export async function runSequence(seq: (() => unknown)[], predicate: () => boolean): Promise<boolean> {
@@ -242,8 +255,8 @@ const [nodeVersion] = process.versions.node.split('.').map(Number);
242
255
  export async function loadThroughTSX<T>(
243
256
  callback: (load: (modulePath: string) => Promise<T>) => Promise<T>,
244
257
  ): Promise<T> {
245
- // TODO Check if it work in node18 and type: 'module'
246
- const unregister = nodeVersion > 18 ? esmRegister() : cjsRegister();
258
+ const unregisterESM = nodeVersion > 18 ? esmRegister() : noop;
259
+ const unregisterCJS = cjsRegister();
247
260
 
248
261
  const result = await callback((modulePath) =>
249
262
  nodeVersion > 18
@@ -254,7 +267,9 @@ export async function loadThroughTSX<T>(
254
267
 
255
268
  // NOTE: `unregister` type is `(() => Promise<void>) | (() => void)`
256
269
  // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
257
- await unregister();
270
+ await unregisterCJS();
271
+ // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
272
+ await unregisterESM();
258
273
 
259
274
  return result;
260
275
  }
@@ -292,3 +307,19 @@ export function waitOnUrl(waitUrl: string, timeout: number, delay: number) {
292
307
  ),
293
308
  );
294
309
  }
310
+
311
+ /**
312
+ * Copies static assets to the report directory
313
+ * @param reportDir Directory where the report will be generated
314
+ */
315
+ export async function copyStatics(reportDir: string): Promise<void> {
316
+ const clientDir = path.join(path.dirname(fileURLToPath(importMetaUrl)), '../../dist/client/web');
317
+ const assets = (await fs.promises.readdir(path.join(clientDir, 'assets'), { withFileTypes: true }))
318
+ .filter((dirent) => dirent.isFile())
319
+ .map((dirent) => dirent.name);
320
+ await fs.promises.mkdir(path.join(reportDir, 'assets'), { recursive: true });
321
+ await fs.promises.copyFile(path.join(clientDir, 'index.html'), path.join(reportDir, 'index.html'));
322
+ for (const asset of assets) {
323
+ await fs.promises.copyFile(path.join(clientDir, 'assets', asset), path.join(reportDir, 'assets', asset));
324
+ }
325
+ }
@@ -218,7 +218,7 @@ export async function start(browser: string, gridUrl: string, config: Config, op
218
218
  browserName: baseContext.browserName,
219
219
  workerId: process.pid,
220
220
  images: imagesContext.images,
221
- error: serializeError(error),
221
+ error: error ? serializeError(error) : undefined,
222
222
  duration,
223
223
  attachments: imagesContext.attachments,
224
224
  retries: message.payload.retries,
package/src/types.ts CHANGED
@@ -268,20 +268,17 @@ export interface Config {
268
268
  /**
269
269
  * Creevey has two built-in stories providers.
270
270
  *
271
- * `nodejsStoriesProvider` - The first one is used by default except if CSFv3 is enabled in Storybook.
272
- * This provider builds and runs storybook in nodejs env, that allows write interaction tests by using Selenium API.
273
- * The downside is it depends from project build specific and slightly increases init time.
274
- *
275
- * `browserStoriesProvider` - The second one is used by default with CSFv3 storybook feature.
276
- * It load stories from storybook which is running in browser, like storyshots or loki do it.
271
+ * `browserStoriesProvider` - Extracts stories directly from the Storybook UI. It loads stories from storybook which is running in browser, like storyshots or loki do it.
277
272
  * The downside of this, you can't use interaction tests in Creevey, unless you use CSFv3.
278
273
  * Where you can define `play` method for each story
279
274
  *
275
+ * `hybridStoriesProvider` - Combines stories from Storybook with tests from separate files. This is the default provider used in the configuration.
276
+ *
280
277
  * Usage
281
278
  * ``` typescript
282
- * import { nodejsStoriesProvider as provider } from 'creevey'
283
- * // or
284
279
  * import { browserStoriesProvider as provider } from 'creevey'
280
+ * // or
281
+ * import { hybridStoriesProvider as provider } from 'creevey'
285
282
  *
286
283
  * // Creevey config
287
284
  * module.exports = {
@@ -353,7 +350,15 @@ export interface Options {
353
350
  _: string[];
354
351
  config?: string;
355
352
  port: number;
353
+ /**
354
+ * Run in UI mode with web interface for reviewing test results
355
+ * When used with `update` flag, enables UI Update Mode for approving screenshots
356
+ */
356
357
  ui: boolean;
358
+ /**
359
+ * Run in update mode to approve failed tests
360
+ * When used with `ui` flag, enables UI Update Mode for approving screenshots from browser
361
+ */
357
362
  update: boolean | string;
358
363
  debug: boolean;
359
364
  trace: boolean;
@@ -529,6 +534,7 @@ export interface CreeveyStatus {
529
534
  isRunning: boolean;
530
535
  tests: Partial<Record<string, TestData>>;
531
536
  browsers: string[];
537
+ isUpdateMode: boolean;
532
538
  }
533
539
 
534
540
  export interface CreeveyUpdate {