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.
- package/CHANGELOG.md +282 -0
- package/dist/client/addon/controller.js +1 -1
- package/dist/client/addon/controller.js.map +1 -1
- package/dist/client/addon/withCreevey.js +1 -18
- package/dist/client/addon/withCreevey.js.map +1 -1
- package/dist/client/shared/components/PageHeader/PageHeader.js +13 -4
- package/dist/client/shared/components/PageHeader/PageHeader.js.map +1 -1
- package/dist/client/shared/creeveyClientApi.js +10 -0
- package/dist/client/shared/creeveyClientApi.js.map +1 -1
- package/dist/client/web/CreeveyApp.d.ts +1 -0
- package/dist/client/web/CreeveyApp.js +1 -0
- package/dist/client/web/CreeveyApp.js.map +1 -1
- package/dist/client/web/CreeveyContext.d.ts +1 -0
- package/dist/client/web/CreeveyContext.js +1 -0
- package/dist/client/web/CreeveyContext.js.map +1 -1
- package/dist/client/web/CreeveyView/SideBar/SideBarFooter.js +9 -8
- package/dist/client/web/CreeveyView/SideBar/SideBarFooter.js.map +1 -1
- package/dist/client/web/CreeveyView/SideBar/SideBarHeader.js +13 -3
- package/dist/client/web/CreeveyView/SideBar/SideBarHeader.js.map +1 -1
- package/dist/client/web/CreeveyView/SideBar/SuiteLink.js +2 -3
- package/dist/client/web/CreeveyView/SideBar/SuiteLink.js.map +1 -1
- package/dist/client/web/CreeveyView/SideBar/TestLink.js +2 -3
- package/dist/client/web/CreeveyView/SideBar/TestLink.js.map +1 -1
- package/dist/client/web/CreeveyView/SideBar/TestsStatus.js +1 -0
- package/dist/client/web/CreeveyView/SideBar/TestsStatus.js.map +1 -1
- package/dist/client/web/assets/{index-C47njyZV.js → index-BU4jjKVC.js} +68 -68
- package/dist/client/web/index.html +1 -1
- package/dist/client/web/index.js +8 -3
- package/dist/client/web/index.js.map +1 -1
- package/dist/creevey.d.ts +1 -1
- package/dist/creevey.js +1 -22
- package/dist/creevey.js.map +1 -1
- package/dist/playwright-reporter.d.ts +2 -0
- package/dist/playwright-reporter.js +5 -0
- package/dist/playwright-reporter.js.map +1 -0
- package/dist/playwright.d.ts +1 -1
- package/dist/server/config.js +8 -1
- package/dist/server/config.js.map +1 -1
- package/dist/server/index.js +10 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/master/api.d.ts +15 -5
- package/dist/server/master/api.js +89 -27
- package/dist/server/master/api.js.map +1 -1
- package/dist/server/master/handlers/capture-handler.d.ts +2 -0
- package/dist/server/master/handlers/capture-handler.js +35 -0
- package/dist/server/master/handlers/capture-handler.js.map +1 -0
- package/dist/server/master/handlers/index.d.ts +4 -0
- package/dist/server/master/handlers/index.js +21 -0
- package/dist/server/master/handlers/index.js.map +1 -0
- package/dist/server/master/handlers/ping-handler.d.ts +2 -0
- package/dist/server/master/handlers/ping-handler.js +7 -0
- package/dist/server/master/handlers/ping-handler.js.map +1 -0
- package/dist/server/master/handlers/static-handler.d.ts +2 -0
- package/dist/server/master/handlers/static-handler.js +32 -0
- package/dist/server/master/handlers/static-handler.js.map +1 -0
- package/dist/server/master/handlers/stories-handler.d.ts +2 -0
- package/dist/server/master/handlers/stories-handler.js +38 -0
- package/dist/server/master/handlers/stories-handler.js.map +1 -0
- package/dist/server/master/master.js +7 -24
- package/dist/server/master/master.js.map +1 -1
- package/dist/server/master/runner.d.ts +4 -6
- package/dist/server/master/runner.js +30 -127
- package/dist/server/master/runner.js.map +1 -1
- package/dist/server/master/server.js +77 -87
- package/dist/server/master/server.js.map +1 -1
- package/dist/server/master/start.d.ts +1 -2
- package/dist/server/master/start.js +11 -29
- package/dist/server/master/start.js.map +1 -1
- package/dist/server/master/testsManager.d.ts +81 -0
- package/dist/server/master/testsManager.js +281 -0
- package/dist/server/master/testsManager.js.map +1 -0
- package/dist/server/playwright/reporter.d.ts +87 -0
- package/dist/server/playwright/reporter.js +351 -0
- package/dist/server/playwright/reporter.js.map +1 -0
- package/dist/server/selenium/internal.js +20 -2
- package/dist/server/selenium/internal.js.map +1 -1
- package/dist/server/selenium/selenoid.js +4 -0
- package/dist/server/selenium/selenoid.js.map +1 -1
- package/dist/server/shutdown.d.ts +1 -0
- package/dist/server/shutdown.js +23 -0
- package/dist/server/shutdown.js.map +1 -0
- package/dist/server/stories.d.ts +0 -1
- package/dist/server/stories.js +0 -12
- package/dist/server/stories.js.map +1 -1
- package/dist/server/ui-update.d.ts +10 -0
- package/dist/server/ui-update.js +39 -0
- package/dist/server/ui-update.js.map +1 -0
- package/dist/server/utils.d.ts +6 -0
- package/dist/server/utils.js +39 -8
- package/dist/server/utils.js.map +1 -1
- package/dist/server/worker/start.js +1 -1
- package/dist/server/worker/start.js.map +1 -1
- package/dist/types.d.ts +14 -8
- package/dist/types.js.map +1 -1
- package/docs/examples/playwright-reporter-example.ts +202 -0
- package/docs/migration-0.9-to-0.10.md +144 -0
- package/docs/playwright-reporter.md +357 -0
- package/package.json +9 -13
- package/src/client/addon/controller.ts +1 -1
- package/src/client/addon/withCreevey.ts +2 -16
- package/src/client/shared/components/PageHeader/PageHeader.tsx +18 -4
- package/src/client/shared/creeveyClientApi.ts +10 -0
- package/src/client/web/CreeveyApp.tsx +2 -0
- package/src/client/web/CreeveyContext.tsx +2 -0
- package/src/client/web/CreeveyView/SideBar/SideBarFooter.tsx +19 -17
- package/src/client/web/CreeveyView/SideBar/SideBarHeader.tsx +18 -3
- package/src/client/web/CreeveyView/SideBar/SuiteLink.tsx +9 -7
- package/src/client/web/CreeveyView/SideBar/TestLink.tsx +8 -6
- package/src/client/web/CreeveyView/SideBar/TestsStatus.tsx +1 -0
- package/src/client/web/index.tsx +8 -3
- package/src/creevey.ts +1 -24
- package/src/playwright-reporter.ts +3 -0
- package/src/server/config.ts +9 -1
- package/src/server/index.ts +11 -4
- package/src/server/master/api.ts +95 -26
- package/src/server/master/handlers/capture-handler.ts +39 -0
- package/src/server/master/handlers/index.ts +4 -0
- package/src/server/master/handlers/ping-handler.ts +5 -0
- package/src/server/master/handlers/static-handler.ts +29 -0
- package/src/server/master/handlers/stories-handler.ts +48 -0
- package/src/server/master/master.ts +10 -27
- package/src/server/master/runner.ts +38 -132
- package/src/server/master/server.ts +93 -97
- package/src/server/master/start.ts +17 -41
- package/src/server/master/testsManager.ts +315 -0
- package/src/server/playwright/reporter.ts +386 -0
- package/src/server/selenium/internal.ts +23 -3
- package/src/server/selenium/selenoid.ts +5 -0
- package/src/server/shutdown.ts +19 -0
- package/src/server/stories.ts +1 -12
- package/src/server/ui-update.ts +46 -0
- package/src/server/utils.ts +40 -9
- package/src/server/worker/start.ts +1 -1
- 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
|
+
}
|