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.
- 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 +12 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/master/api.d.ts +11 -6
- package/dist/server/master/api.js +88 -25
- package/dist/server/master/api.js.map +1 -1
- package/dist/server/master/handlers/capture-handler.d.ts +5 -0
- package/dist/server/master/handlers/capture-handler.js +25 -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 +8 -0
- package/dist/server/master/handlers/ping-handler.js.map +1 -0
- package/dist/server/master/handlers/static-handler.d.ts +1 -0
- package/dist/server/master/handlers/static-handler.js +22 -0
- package/dist/server/master/handlers/static-handler.js.map +1 -0
- package/dist/server/master/handlers/stories-handler.d.ts +4 -0
- package/dist/server/master/handlers/stories-handler.js +24 -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 +191 -89
- 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/docker-file.js +2 -2
- package/dist/server/playwright/docker-file.js.map +1 -1
- 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 +10 -14
- 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 +13 -6
- package/src/server/master/api.ts +94 -28
- package/src/server/master/handlers/capture-handler.ts +20 -0
- package/src/server/master/handlers/index.ts +4 -0
- package/src/server/master/handlers/ping-handler.ts +6 -0
- package/src/server/master/handlers/static-handler.ts +18 -0
- package/src/server/master/handlers/stories-handler.ts +20 -0
- package/src/server/master/master.ts +10 -27
- package/src/server/master/runner.ts +38 -132
- package/src/server/master/server.ts +210 -98
- package/src/server/master/start.ts +17 -41
- package/src/server/master/testsManager.ts +315 -0
- package/src/server/playwright/docker-file.ts +2 -2
- 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,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
|
377
|
-
|
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
|
-
|
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
|
+
});
|
package/src/server/stories.ts
CHANGED
@@ -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
|
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
|
+
}
|
package/src/server/utils.ts
CHANGED
@@ -1,16 +1,17 @@
|
|
1
1
|
import fs from 'fs';
|
2
|
-
import
|
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
|
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
|
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
|
-
|
246
|
-
const
|
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
|
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
|
-
* `
|
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 {
|