creevey 0.10.0-beta.43 → 0.10.0-beta.44

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 (134) 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 +10 -3
  40. package/dist/server/index.js.map +1 -1
  41. package/dist/server/master/api.d.ts +15 -5
  42. package/dist/server/master/api.js +89 -27
  43. package/dist/server/master/api.js.map +1 -1
  44. package/dist/server/master/handlers/capture-handler.d.ts +2 -0
  45. package/dist/server/master/handlers/capture-handler.js +35 -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 +7 -0
  52. package/dist/server/master/handlers/ping-handler.js.map +1 -0
  53. package/dist/server/master/handlers/static-handler.d.ts +2 -0
  54. package/dist/server/master/handlers/static-handler.js +32 -0
  55. package/dist/server/master/handlers/static-handler.js.map +1 -0
  56. package/dist/server/master/handlers/stories-handler.d.ts +2 -0
  57. package/dist/server/master/handlers/stories-handler.js +38 -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 +77 -87
  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/reporter.d.ts +87 -0
  73. package/dist/server/playwright/reporter.js +351 -0
  74. package/dist/server/playwright/reporter.js.map +1 -0
  75. package/dist/server/selenium/internal.js +20 -2
  76. package/dist/server/selenium/internal.js.map +1 -1
  77. package/dist/server/selenium/selenoid.js +4 -0
  78. package/dist/server/selenium/selenoid.js.map +1 -1
  79. package/dist/server/shutdown.d.ts +1 -0
  80. package/dist/server/shutdown.js +23 -0
  81. package/dist/server/shutdown.js.map +1 -0
  82. package/dist/server/stories.d.ts +0 -1
  83. package/dist/server/stories.js +0 -12
  84. package/dist/server/stories.js.map +1 -1
  85. package/dist/server/ui-update.d.ts +10 -0
  86. package/dist/server/ui-update.js +39 -0
  87. package/dist/server/ui-update.js.map +1 -0
  88. package/dist/server/utils.d.ts +6 -0
  89. package/dist/server/utils.js +39 -8
  90. package/dist/server/utils.js.map +1 -1
  91. package/dist/server/worker/start.js +1 -1
  92. package/dist/server/worker/start.js.map +1 -1
  93. package/dist/types.d.ts +14 -8
  94. package/dist/types.js.map +1 -1
  95. package/docs/examples/playwright-reporter-example.ts +202 -0
  96. package/docs/migration-0.9-to-0.10.md +144 -0
  97. package/docs/playwright-reporter.md +357 -0
  98. package/package.json +9 -13
  99. package/src/client/addon/controller.ts +1 -1
  100. package/src/client/addon/withCreevey.ts +2 -16
  101. package/src/client/shared/components/PageHeader/PageHeader.tsx +18 -4
  102. package/src/client/shared/creeveyClientApi.ts +10 -0
  103. package/src/client/web/CreeveyApp.tsx +2 -0
  104. package/src/client/web/CreeveyContext.tsx +2 -0
  105. package/src/client/web/CreeveyView/SideBar/SideBarFooter.tsx +19 -17
  106. package/src/client/web/CreeveyView/SideBar/SideBarHeader.tsx +18 -3
  107. package/src/client/web/CreeveyView/SideBar/SuiteLink.tsx +9 -7
  108. package/src/client/web/CreeveyView/SideBar/TestLink.tsx +8 -6
  109. package/src/client/web/CreeveyView/SideBar/TestsStatus.tsx +1 -0
  110. package/src/client/web/index.tsx +8 -3
  111. package/src/creevey.ts +1 -24
  112. package/src/playwright-reporter.ts +3 -0
  113. package/src/server/config.ts +9 -1
  114. package/src/server/index.ts +11 -4
  115. package/src/server/master/api.ts +95 -26
  116. package/src/server/master/handlers/capture-handler.ts +39 -0
  117. package/src/server/master/handlers/index.ts +4 -0
  118. package/src/server/master/handlers/ping-handler.ts +5 -0
  119. package/src/server/master/handlers/static-handler.ts +29 -0
  120. package/src/server/master/handlers/stories-handler.ts +48 -0
  121. package/src/server/master/master.ts +10 -27
  122. package/src/server/master/runner.ts +38 -132
  123. package/src/server/master/server.ts +93 -97
  124. package/src/server/master/start.ts +17 -41
  125. package/src/server/master/testsManager.ts +315 -0
  126. package/src/server/playwright/reporter.ts +386 -0
  127. package/src/server/selenium/internal.ts +23 -3
  128. package/src/server/selenium/selenoid.ts +5 -0
  129. package/src/server/shutdown.ts +19 -0
  130. package/src/server/stories.ts +1 -12
  131. package/src/server/ui-update.ts +46 -0
  132. package/src/server/utils.ts +40 -9
  133. package/src/server/worker/start.ts +1 -1
  134. package/src/types.ts +14 -8
@@ -0,0 +1,315 @@
1
+ import path from 'path';
2
+ import { mkdirSync, writeFileSync } from 'fs';
3
+ import EventEmitter from 'events';
4
+ import {
5
+ ServerTest,
6
+ TestMeta,
7
+ TestResult,
8
+ TestStatus,
9
+ CreeveyUpdate,
10
+ ApprovePayload,
11
+ isDefined,
12
+ isFunction,
13
+ CreeveyStatus,
14
+ } from '../../types.js';
15
+ import { tryToLoadTestsData } from '../utils.js';
16
+ import { copyFile, mkdir, writeFile } from 'fs/promises';
17
+
18
+ /**
19
+ * TestsManager is responsible for all operations related to test data management
20
+ * including loading, saving, merging, and updating test data.
21
+ * It extends EventEmitter to emit update events that can be subscribed to.
22
+ */
23
+ export class TestsManager extends EventEmitter {
24
+ private tests: Partial<Record<string, ServerTest>> = {};
25
+ private screenDir: string;
26
+ private reportDir: string;
27
+
28
+ /**
29
+ * Creates a new TestsManager instance
30
+ * @param screenDir Directory for storing reference images
31
+ * @param reportDir Directory for storing reports and screenshots
32
+ */
33
+ constructor(screenDir: string, reportDir: string) {
34
+ super();
35
+ this.screenDir = screenDir;
36
+ this.reportDir = reportDir;
37
+ }
38
+
39
+ /**
40
+ * Get a copy of all tests
41
+ * @returns all tests
42
+ */
43
+ public getTests(): Partial<Record<string, ServerTest>> {
44
+ return this.tests;
45
+ }
46
+
47
+ /**
48
+ * Get a test by ID
49
+ * @param id Test ID
50
+ * @returns Test data
51
+ */
52
+ public getTest(id: string): ServerTest | undefined {
53
+ return this.tests[id];
54
+ }
55
+
56
+ /**
57
+ * Get test data in a format suitable for status reporting
58
+ * @returns Test data in the format needed for status
59
+ */
60
+ public getTestsData(): CreeveyStatus['tests'] {
61
+ const testsData: CreeveyStatus['tests'] = {};
62
+
63
+ Object.entries(this.tests).forEach(([id, test]) => {
64
+ if (!test) return;
65
+
66
+ const { story: _, fn: __, ...testData } = test;
67
+ testsData[id] = testData;
68
+ });
69
+
70
+ return testsData;
71
+ }
72
+
73
+ /**
74
+ * Load tests from a report file
75
+ */
76
+ public loadTestsFromReport(): Partial<Record<string, ServerTest>> {
77
+ const reportDataPath = path.join(this.reportDir, 'data.js');
78
+ const testsFromReport = tryToLoadTestsData(reportDataPath) ?? {};
79
+ return testsFromReport;
80
+ }
81
+
82
+ /**
83
+ * Merge tests from report with tests from stories
84
+ */
85
+ private mergeTests(
86
+ testsWithReports: CreeveyStatus['tests'],
87
+ testsFromStories: Partial<Record<string, ServerTest>>,
88
+ ): Partial<Record<string, ServerTest>> {
89
+ Object.values(testsFromStories)
90
+ .filter(isDefined)
91
+ .forEach((test) => {
92
+ const testWithReport = testsWithReports[test.id];
93
+ if (!testWithReport) return;
94
+ test.retries = testWithReport.retries;
95
+ if (testWithReport.status === 'success' || testWithReport.status === 'failed') {
96
+ test.status = testWithReport.status;
97
+ }
98
+ test.results = testWithReport.results;
99
+ test.approved = testWithReport.approved;
100
+ });
101
+
102
+ return testsFromStories;
103
+ }
104
+
105
+ public loadAndMergeTests(testsFromStories: Partial<Record<string, ServerTest>>): Partial<Record<string, ServerTest>> {
106
+ const testsFromReport = this.loadTestsFromReport();
107
+
108
+ return this.mergeTests(testsFromReport, testsFromStories);
109
+ }
110
+
111
+ /**
112
+ * Update tests with incremental changes
113
+ * @param testsDiff Tests to update or remove
114
+ */
115
+ public updateTests(testsDiff: Partial<Record<string, ServerTest>>): CreeveyUpdate | null {
116
+ const tests: CreeveyUpdate['tests'] = {};
117
+ const removedTests: TestMeta[] = [];
118
+
119
+ Object.entries(testsDiff).forEach(([id, newTest]) => {
120
+ if (newTest) {
121
+ if (this.tests[id]) {
122
+ this.tests[id] = {
123
+ ...newTest,
124
+ retries: this.tests[id].retries,
125
+ results: this.tests[id].results,
126
+ approved: this.tests[id].approved,
127
+ };
128
+ } else {
129
+ this.tests[id] = newTest;
130
+ }
131
+
132
+ const { story: _, fn: __, ...restTest } = newTest;
133
+ tests[id] = { ...restTest, status: 'unknown' };
134
+ } else if (this.tests[id]) {
135
+ const { id: testId, browser, testName, storyPath, storyId } = this.tests[id];
136
+ removedTests.push({ id: testId, browser, testName, storyPath, storyId });
137
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
138
+ delete this.tests[id];
139
+ }
140
+ });
141
+
142
+ this.saveTestsToJson();
143
+
144
+ const update = { tests, removedTests };
145
+ this.emit('update', update);
146
+ return update;
147
+ }
148
+
149
+ /**
150
+ * Update test result
151
+ * @param id Test ID
152
+ * @param status New test status
153
+ * @param result Optional test result
154
+ */
155
+ public updateTestStatus(id: string, status: TestStatus, result?: TestResult): CreeveyUpdate | null {
156
+ // TODO Handle 'retrying' status
157
+ const test = this.tests[id];
158
+ if (!test) return null;
159
+
160
+ const { browser, testName, storyPath, storyId } = test;
161
+ test.status = status === 'retrying' ? 'failed' : status;
162
+
163
+ if (!result) {
164
+ // NOTE: Running status
165
+ const update = { tests: { [id]: { id, browser, testName, storyPath, status, storyId } } };
166
+ this.emit('update', update);
167
+ return update;
168
+ }
169
+
170
+ test.results ??= [];
171
+ test.results.push(result);
172
+
173
+ if (status === 'failed') {
174
+ test.approved = null;
175
+ }
176
+
177
+ const update = {
178
+ tests: {
179
+ [id]: {
180
+ id,
181
+ browser,
182
+ testName,
183
+ storyPath,
184
+ status,
185
+ approved: test.approved,
186
+ results: [result],
187
+ storyId,
188
+ },
189
+ },
190
+ };
191
+
192
+ this.emit('update', update);
193
+ return update;
194
+ }
195
+
196
+ /**
197
+ * Save tests to JSON file
198
+ * @param reportDir Directory to save the JSON file
199
+ */
200
+ public saveTestsToJson(): void {
201
+ mkdirSync(this.reportDir, { recursive: true });
202
+ writeFileSync(
203
+ path.join(this.reportDir, 'tests.json'),
204
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
205
+ JSON.stringify(this.tests, (_, value) => (isFunction(value) ? value.toString() : value), 2),
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Save test data to a module
211
+ * @param data Test data to include in the module
212
+ */
213
+ public async saveTestData(data: CreeveyStatus['tests'] = this.getTestsData()): Promise<void> {
214
+ const dataModule = `
215
+ (function (root, factory) {
216
+ if (typeof module === 'object' && module.exports) {
217
+ module.exports = factory();
218
+ } else {
219
+ root.__CREEVEY_DATA__ = factory();
220
+ }
221
+ }(this, function () { return ${JSON.stringify(data)} }));
222
+ `;
223
+ await writeFile(path.join(this.reportDir, 'data.js'), dataModule);
224
+ }
225
+
226
+ /**
227
+ * Copy image for approval
228
+ * @param test Test data
229
+ * @param image Image name
230
+ * @param actual Actual image path
231
+ */
232
+ private async copyImage(test: ServerTest, image: string, actual: string): Promise<void> {
233
+ const { browser, testName, storyPath } = test;
234
+ const restPath = [...storyPath, testName].filter(isDefined);
235
+ const testPath = path.join(...restPath, image == browser ? '' : browser);
236
+ const srcImagePath = path.join(this.reportDir, testPath, actual);
237
+ const dstImagePath = path.join(this.screenDir, testPath, `${image}.png`);
238
+ await mkdir(path.join(this.screenDir, testPath), { recursive: true });
239
+ await copyFile(srcImagePath, dstImagePath);
240
+ }
241
+
242
+ /**
243
+ * Approve a specific test
244
+ * @param payload Approval payload with test ID, retry index, and image name
245
+ */
246
+ public async approve({ id, retry, image }: ApprovePayload): Promise<CreeveyUpdate | null> {
247
+ const test = this.tests[id];
248
+ if (!test?.results) return null;
249
+ const result = test.results[retry];
250
+ if (!result.images) return null;
251
+ const images = result.images[image];
252
+ if (!images) return null;
253
+ test.approved ??= {};
254
+ const { browser, testName, storyPath, storyId } = test;
255
+
256
+ await this.copyImage(test, image, images.actual);
257
+
258
+ test.approved[image] = retry;
259
+
260
+ if (Object.keys(result.images).every((name) => typeof test.approved?.[name] == 'number')) {
261
+ test.status = 'approved';
262
+ }
263
+
264
+ const update = {
265
+ tests: {
266
+ [id]: {
267
+ id,
268
+ browser,
269
+ testName,
270
+ storyPath,
271
+ status: test.status,
272
+ approved: test.approved,
273
+ storyId,
274
+ },
275
+ },
276
+ };
277
+
278
+ this.emit('update', update);
279
+ return update;
280
+ }
281
+
282
+ /**
283
+ * Approve all failed tests
284
+ */
285
+ public async approveAll(): Promise<CreeveyUpdate> {
286
+ const updatedTests: NonNullable<CreeveyUpdate['tests']> = {};
287
+ for (const test of Object.values(this.tests)) {
288
+ if (!test?.results) continue;
289
+ const retry = test.results.length - 1;
290
+ const { images, status } = test.results.at(retry) ?? {};
291
+ if (!images || status != 'failed') continue;
292
+ for (const [name, image] of Object.entries(images)) {
293
+ if (!image) continue;
294
+ await this.copyImage(test, name, image.actual);
295
+
296
+ test.approved ??= {};
297
+ test.approved[name] = retry;
298
+ test.status = 'approved';
299
+
300
+ updatedTests[test.id] = {
301
+ id: test.id,
302
+ browser: test.browser,
303
+ storyPath: test.storyPath,
304
+ storyId: test.storyId,
305
+ status: test.status,
306
+ approved: { [name]: retry },
307
+ };
308
+ }
309
+ }
310
+
311
+ const result = { tests: updatedTests };
312
+ this.emit('update', result);
313
+ return result;
314
+ }
315
+ }
@@ -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
+ }