creevey 0.10.0-beta.42 → 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 (141) 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/decorator.d.ts +1 -1
  5. package/dist/client/addon/makeDecorator.d.ts +9 -0
  6. package/dist/client/addon/makeDecorator.js +48 -0
  7. package/dist/client/addon/makeDecorator.js.map +1 -0
  8. package/dist/client/addon/preview.d.ts +1 -1
  9. package/dist/client/addon/withCreevey.d.ts +2 -1
  10. package/dist/client/addon/withCreevey.js +3 -20
  11. package/dist/client/addon/withCreevey.js.map +1 -1
  12. package/dist/client/shared/components/PageHeader/PageHeader.js +13 -4
  13. package/dist/client/shared/components/PageHeader/PageHeader.js.map +1 -1
  14. package/dist/client/shared/creeveyClientApi.js +10 -0
  15. package/dist/client/shared/creeveyClientApi.js.map +1 -1
  16. package/dist/client/web/CreeveyApp.d.ts +1 -0
  17. package/dist/client/web/CreeveyApp.js +1 -0
  18. package/dist/client/web/CreeveyApp.js.map +1 -1
  19. package/dist/client/web/CreeveyContext.d.ts +1 -0
  20. package/dist/client/web/CreeveyContext.js +1 -0
  21. package/dist/client/web/CreeveyContext.js.map +1 -1
  22. package/dist/client/web/CreeveyView/SideBar/SideBarFooter.js +9 -8
  23. package/dist/client/web/CreeveyView/SideBar/SideBarFooter.js.map +1 -1
  24. package/dist/client/web/CreeveyView/SideBar/SideBarHeader.js +13 -3
  25. package/dist/client/web/CreeveyView/SideBar/SideBarHeader.js.map +1 -1
  26. package/dist/client/web/CreeveyView/SideBar/SuiteLink.js +2 -3
  27. package/dist/client/web/CreeveyView/SideBar/SuiteLink.js.map +1 -1
  28. package/dist/client/web/CreeveyView/SideBar/TestLink.js +2 -3
  29. package/dist/client/web/CreeveyView/SideBar/TestLink.js.map +1 -1
  30. package/dist/client/web/CreeveyView/SideBar/TestsStatus.js +1 -0
  31. package/dist/client/web/CreeveyView/SideBar/TestsStatus.js.map +1 -1
  32. package/dist/client/web/assets/{index-C47njyZV.js → index-BU4jjKVC.js} +68 -68
  33. package/dist/client/web/index.html +1 -1
  34. package/dist/client/web/index.js +8 -3
  35. package/dist/client/web/index.js.map +1 -1
  36. package/dist/creevey.d.ts +1 -1
  37. package/dist/creevey.js +1 -22
  38. package/dist/creevey.js.map +1 -1
  39. package/dist/playwright-reporter.d.ts +2 -0
  40. package/dist/playwright-reporter.js +5 -0
  41. package/dist/playwright-reporter.js.map +1 -0
  42. package/dist/playwright.d.ts +1 -1
  43. package/dist/server/config.js +8 -1
  44. package/dist/server/config.js.map +1 -1
  45. package/dist/server/index.js +10 -3
  46. package/dist/server/index.js.map +1 -1
  47. package/dist/server/master/api.d.ts +15 -5
  48. package/dist/server/master/api.js +89 -27
  49. package/dist/server/master/api.js.map +1 -1
  50. package/dist/server/master/handlers/capture-handler.d.ts +2 -0
  51. package/dist/server/master/handlers/capture-handler.js +35 -0
  52. package/dist/server/master/handlers/capture-handler.js.map +1 -0
  53. package/dist/server/master/handlers/index.d.ts +4 -0
  54. package/dist/server/master/handlers/index.js +21 -0
  55. package/dist/server/master/handlers/index.js.map +1 -0
  56. package/dist/server/master/handlers/ping-handler.d.ts +2 -0
  57. package/dist/server/master/handlers/ping-handler.js +7 -0
  58. package/dist/server/master/handlers/ping-handler.js.map +1 -0
  59. package/dist/server/master/handlers/static-handler.d.ts +2 -0
  60. package/dist/server/master/handlers/static-handler.js +32 -0
  61. package/dist/server/master/handlers/static-handler.js.map +1 -0
  62. package/dist/server/master/handlers/stories-handler.d.ts +2 -0
  63. package/dist/server/master/handlers/stories-handler.js +38 -0
  64. package/dist/server/master/handlers/stories-handler.js.map +1 -0
  65. package/dist/server/master/master.js +7 -24
  66. package/dist/server/master/master.js.map +1 -1
  67. package/dist/server/master/runner.d.ts +4 -6
  68. package/dist/server/master/runner.js +30 -127
  69. package/dist/server/master/runner.js.map +1 -1
  70. package/dist/server/master/server.js +77 -87
  71. package/dist/server/master/server.js.map +1 -1
  72. package/dist/server/master/start.d.ts +1 -2
  73. package/dist/server/master/start.js +11 -29
  74. package/dist/server/master/start.js.map +1 -1
  75. package/dist/server/master/testsManager.d.ts +81 -0
  76. package/dist/server/master/testsManager.js +281 -0
  77. package/dist/server/master/testsManager.js.map +1 -0
  78. package/dist/server/playwright/reporter.d.ts +87 -0
  79. package/dist/server/playwright/reporter.js +351 -0
  80. package/dist/server/playwright/reporter.js.map +1 -0
  81. package/dist/server/selenium/internal.js +20 -2
  82. package/dist/server/selenium/internal.js.map +1 -1
  83. package/dist/server/selenium/selenoid.js +4 -0
  84. package/dist/server/selenium/selenoid.js.map +1 -1
  85. package/dist/server/shutdown.d.ts +1 -0
  86. package/dist/server/shutdown.js +23 -0
  87. package/dist/server/shutdown.js.map +1 -0
  88. package/dist/server/stories.d.ts +0 -1
  89. package/dist/server/stories.js +0 -12
  90. package/dist/server/stories.js.map +1 -1
  91. package/dist/server/ui-update.d.ts +10 -0
  92. package/dist/server/ui-update.js +39 -0
  93. package/dist/server/ui-update.js.map +1 -0
  94. package/dist/server/utils.d.ts +6 -0
  95. package/dist/server/utils.js +39 -8
  96. package/dist/server/utils.js.map +1 -1
  97. package/dist/server/worker/start.js +1 -1
  98. package/dist/server/worker/start.js.map +1 -1
  99. package/dist/types.d.ts +14 -8
  100. package/dist/types.js.map +1 -1
  101. package/docs/examples/playwright-reporter-example.ts +202 -0
  102. package/docs/migration-0.9-to-0.10.md +144 -0
  103. package/docs/playwright-reporter.md +357 -0
  104. package/package.json +9 -13
  105. package/src/client/addon/controller.ts +1 -1
  106. package/src/client/addon/makeDecorator.ts +69 -0
  107. package/src/client/addon/withCreevey.ts +4 -17
  108. package/src/client/shared/components/PageHeader/PageHeader.tsx +18 -4
  109. package/src/client/shared/creeveyClientApi.ts +10 -0
  110. package/src/client/web/CreeveyApp.tsx +2 -0
  111. package/src/client/web/CreeveyContext.tsx +2 -0
  112. package/src/client/web/CreeveyView/SideBar/SideBarFooter.tsx +19 -17
  113. package/src/client/web/CreeveyView/SideBar/SideBarHeader.tsx +18 -3
  114. package/src/client/web/CreeveyView/SideBar/SuiteLink.tsx +9 -7
  115. package/src/client/web/CreeveyView/SideBar/TestLink.tsx +8 -6
  116. package/src/client/web/CreeveyView/SideBar/TestsStatus.tsx +1 -0
  117. package/src/client/web/index.tsx +8 -3
  118. package/src/creevey.ts +1 -24
  119. package/src/playwright-reporter.ts +3 -0
  120. package/src/server/config.ts +9 -1
  121. package/src/server/index.ts +11 -4
  122. package/src/server/master/api.ts +95 -26
  123. package/src/server/master/handlers/capture-handler.ts +39 -0
  124. package/src/server/master/handlers/index.ts +4 -0
  125. package/src/server/master/handlers/ping-handler.ts +5 -0
  126. package/src/server/master/handlers/static-handler.ts +29 -0
  127. package/src/server/master/handlers/stories-handler.ts +48 -0
  128. package/src/server/master/master.ts +10 -27
  129. package/src/server/master/runner.ts +38 -132
  130. package/src/server/master/server.ts +93 -97
  131. package/src/server/master/start.ts +17 -41
  132. package/src/server/master/testsManager.ts +315 -0
  133. package/src/server/playwright/reporter.ts +386 -0
  134. package/src/server/selenium/internal.ts +23 -3
  135. package/src/server/selenium/selenoid.ts +5 -0
  136. package/src/server/shutdown.ts +19 -0
  137. package/src/server/stories.ts +1 -12
  138. package/src/server/ui-update.ts +46 -0
  139. package/src/server/utils.ts +40 -9
  140. package/src/server/worker/start.ts +1 -1
  141. package/src/types.ts +14 -8
@@ -1,125 +1,121 @@
1
1
  import path from 'path';
2
- import http from 'http';
3
- import cluster from 'cluster';
4
- import Koa from 'koa';
5
- import cors from '@koa/cors';
6
- import serve from 'koa-static';
7
- import mount from 'koa-mount';
8
- import body from 'koa-bodyparser';
9
- import WebSocket from 'ws';
2
+ import HyperExpress from 'hyper-express';
10
3
  import { fileURLToPath, pathToFileURL } from 'url';
11
4
  import { CreeveyApi } from './api.js';
12
- import { emitStoriesMessage, sendStoriesMessage, subscribeOn, subscribeOnWorker } from '../messages.js';
13
- import { CaptureOptions, isDefined, noop, StoryInput } from '../../types.js';
5
+ import { subscribeOn } from '../messages.js';
6
+ import { noop } from '../../types.js';
14
7
  import { logger } from '../logger.js';
15
- import { deserializeStory } from '../../shared/index.js';
8
+ import { pingHandler, createStoriesHandler, captureHandler, createStaticFileHandler } from './handlers/index.js';
16
9
 
17
10
  const importMetaUrl = pathToFileURL(__filename).href;
18
11
 
19
12
  export function start(reportDir: string, port: number, ui: boolean): (api: CreeveyApi) => void {
20
13
  let resolveApi: (api: CreeveyApi) => void = noop;
21
- let setStoriesCounter = 0;
22
14
  const creeveyApi = new Promise<CreeveyApi>((resolve) => (resolveApi = resolve));
23
- const app = new Koa();
24
- // eslint-disable-next-line @typescript-eslint/no-misused-promises
25
- const server = http.createServer(app.callback());
26
- const wss = new WebSocket.Server({ server });
27
-
28
- app.use(cors());
29
- app.use(body());
30
-
31
- app.use(async (ctx, next) => {
32
- if (ctx.method == 'GET' && ctx.path == '/ping') {
33
- ctx.body = 'pong';
34
- return;
35
- }
36
- await next();
37
- });
38
15
 
39
- if (ui) {
40
- app.use(async (_, next) => {
41
- await creeveyApi;
42
- await next();
43
- });
44
- }
16
+ // Create HyperExpress server instance
17
+ const server = new HyperExpress.Server();
45
18
 
46
- app.use(async (ctx, next) => {
47
- if (ctx.method == 'POST' && ctx.path == '/stories') {
48
- const { setStoriesCounter: counter, stories } = ctx.request.body as {
49
- setStoriesCounter: number;
50
- stories: [string, StoryInput[]][];
51
- };
52
- if (setStoriesCounter >= counter) return;
53
-
54
- const deserializedStories = stories.map<[string, StoryInput[]]>(([file, stories]) => [
55
- file,
56
- stories.map(deserializeStory),
57
- ]);
58
-
59
- setStoriesCounter = counter;
60
- emitStoriesMessage({ type: 'update', payload: deserializedStories });
61
- Object.values(cluster.workers ?? {})
62
- .filter(isDefined)
63
- .filter((worker) => worker.isConnected())
64
- .forEach((worker) => {
65
- sendStoriesMessage(worker, { type: 'update', payload: deserializedStories });
66
- });
67
- return;
68
- }
69
- await next();
70
- });
19
+ // Store active WebSocket connections
20
+ const activeConnections = new Set<HyperExpress.Websocket>();
71
21
 
72
- app.use(async (ctx, next) => {
73
- if (ctx.method == 'POST' && ctx.path == '/capture') {
74
- const { workerId, options } = ctx.request.body as { workerId: number; options?: CaptureOptions };
75
- const worker = Object.values(cluster.workers ?? {})
76
- .filter(isDefined)
77
- .find((worker) => worker.process.pid == workerId);
78
- // NOTE: Hypothetical case when someone send to us capture req and we don't have a worker with browser session for it
79
- if (!worker) return;
80
- await new Promise<void>((resolve) => {
81
- const unsubscribe = subscribeOnWorker(worker, 'stories', (message) => {
82
- if (message.type != 'capture') return;
83
- unsubscribe();
84
- resolve();
85
- });
86
- sendStoriesMessage(worker, { type: 'capture', payload: options });
87
- });
88
- // TODO Pass screenshot result to show it in inspector
89
- ctx.body = 'Ok';
90
- return;
22
+ // Enable CORS for all routes
23
+ server.use((request, response, next) => {
24
+ response.header('Access-Control-Allow-Origin', '*');
25
+ response.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
26
+ response.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
27
+
28
+ if (request.method === 'OPTIONS') {
29
+ return response.status(200).send();
91
30
  }
92
- await next();
31
+
32
+ next();
93
33
  });
94
34
 
95
- app.use(serve(path.join(path.dirname(fileURLToPath(importMetaUrl)), '../../client/web')));
96
- app.use(mount('/report', serve(reportDir)));
35
+ // Health check endpoint
36
+ server.get('/ping', pingHandler);
97
37
 
98
- wss.on('error', (error) => {
99
- logger().error(error);
100
- });
38
+ // Stories endpoint
39
+ server.post('/stories', createStoriesHandler());
101
40
 
102
- server.listen(port);
41
+ // Capture endpoint
42
+ server.post('/capture', captureHandler);
103
43
 
104
- subscribeOn('shutdown', () => {
105
- server.close();
106
- wss.close();
107
- wss.clients.forEach((ws) => {
108
- ws.close();
44
+ // Serve report files
45
+ server.get('/report/*', createStaticFileHandler(reportDir, '/report/'));
46
+
47
+ // Serve static files
48
+ const webDir = path.join(path.dirname(fileURLToPath(importMetaUrl)), '../../client/web');
49
+ server.get('/*', createStaticFileHandler(webDir));
50
+
51
+ // If UI mode, wait for CreeveyApi to be resolved
52
+ if (ui) {
53
+ // Create a custom broadcast function that works with our connections
54
+ const broadcast = (message: string) => {
55
+ for (const connection of activeConnections) {
56
+ connection.send(message);
57
+ }
58
+ };
59
+
60
+ // Create a custom WebSocket server that simulates the standard behavior
61
+ const customWsServer = {
62
+ clients: activeConnections,
63
+ publish: broadcast,
64
+ };
65
+
66
+ let api: CreeveyApi | null = null;
67
+
68
+ server.use(async (request, _response, next) => {
69
+ if (!api && request.path === '/') {
70
+ api = await creeveyApi;
71
+ api.subscribe(customWsServer);
72
+ }
73
+ next();
109
74
  });
110
- });
111
75
 
112
- void creeveyApi.then((api) => {
113
- api.subscribe(wss);
76
+ // Create WebSocket listener
77
+ server.ws('/', (ws) => {
78
+ // Add connection to the set of active connections
79
+ activeConnections.add(ws);
114
80
 
115
- wss.on('connection', (ws) => {
116
- ws.on('message', (message: WebSocket.RawData, isBinary: boolean) => {
117
- // NOTE Text messages are passed as Buffer https://github.com/websockets/ws/releases/tag/8.0.0
118
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
119
- api.handleMessage(ws, isBinary ? message : message.toString('utf-8'));
81
+ // Handle message events
82
+ ws.on('message', (message: string | Buffer) => {
83
+ api?.handleMessage(ws, message);
84
+ });
85
+
86
+ // Handle close events to clean up connections
87
+ ws.on('close', () => {
88
+ activeConnections.delete(ws);
120
89
  });
121
90
  });
91
+ }
92
+
93
+ // Shutdown handling
94
+ subscribeOn('shutdown', () => {
95
+ // Close all WebSocket connections
96
+ for (const connection of activeConnections) {
97
+ try {
98
+ connection.close();
99
+ } catch (error) {
100
+ logger().error('Error closing WebSocket connection', error);
101
+ }
102
+ }
103
+
104
+ // Close the server
105
+ server.close();
122
106
  });
123
107
 
108
+ // Start server
109
+ server
110
+ .listen(port)
111
+ .then(() => {
112
+ logger().info(`Server starting on port ${port}`);
113
+ })
114
+ .catch((error: unknown) => {
115
+ logger().error('Failed to start server', error);
116
+ process.exit(1);
117
+ });
118
+
119
+ // Return the function to resolve the API
124
120
  return resolveApi;
125
121
  }
@@ -1,41 +1,14 @@
1
1
  import path from 'path';
2
2
  import { existsSync } from 'fs';
3
- import { fileURLToPath, pathToFileURL } from 'url';
4
- import { copyFile, readdir, mkdir, writeFile } from 'fs/promises';
5
3
  import master from './master.js';
6
- import creeveyApi, { CreeveyApi } from './api.js';
7
- import { Config, Options, TestData, isDefined } from '../../types.js';
8
- import { shutdownWorkers, testsToImages, readDirRecursive } from '../utils.js';
4
+ import { CreeveyApi } from './api.js';
5
+ import { Config, Options, isDefined } from '../../types.js';
6
+ import { shutdownWorkers, testsToImages, readDirRecursive, copyStatics } from '../utils.js';
9
7
  import { subscribeOn } from '../messages.js';
10
8
  import Runner from './runner.js';
11
9
  import { logger } from '../logger.js';
12
10
  import { sendScreenshotsCount } from '../telemetry.js';
13
-
14
- const importMetaUrl = pathToFileURL(__filename).href;
15
-
16
- async function copyStatics(reportDir: string): Promise<void> {
17
- const clientDir = path.join(path.dirname(fileURLToPath(importMetaUrl)), '../../../dist/client/web');
18
- const assets = (await readdir(path.join(clientDir, 'assets'), { withFileTypes: true }))
19
- .filter((dirent) => dirent.isFile())
20
- .map((dirent) => dirent.name);
21
- await mkdir(path.join(reportDir, 'assets'), { recursive: true });
22
- await copyFile(path.join(clientDir, 'index.html'), path.join(reportDir, 'index.html'));
23
- for (const asset of assets) {
24
- await copyFile(path.join(clientDir, 'assets', asset), path.join(reportDir, 'assets', asset));
25
- }
26
- }
27
-
28
- function reportDataModule(data: Partial<Record<string, TestData>>): string {
29
- return `
30
- (function (root, factory) {
31
- if (typeof module === 'object' && module.exports) {
32
- module.exports = factory();
33
- } else {
34
- root.__CREEVEY_DATA__ = factory();
35
- }
36
- }(this, function () { return ${JSON.stringify(data)} }));
37
- `;
38
- }
11
+ import { start as startServer } from './server.js';
39
12
 
40
13
  function outputUnnecessaryImages(imagesDir: string, images: Set<string>): void {
41
14
  if (!existsSync(imagesDir)) return;
@@ -50,12 +23,9 @@ function outputUnnecessaryImages(imagesDir: string, images: Set<string>): void {
50
23
  }
51
24
  }
52
25
 
53
- export async function start(
54
- gridUrl: string | undefined,
55
- config: Config,
56
- options: Options,
57
- resolveApi: (api: CreeveyApi) => void,
58
- ): Promise<void> {
26
+ export async function start(gridUrl: string | undefined, config: Config, options: Options): Promise<void> {
27
+ const resolveApi = startServer(config.reportDir, options.port, options.ui);
28
+
59
29
  let runner: Runner | null = null;
60
30
  if (config.hooks.before) {
61
31
  await config.hooks.before();
@@ -78,13 +48,19 @@ export async function start(
78
48
  runner = await master(config, gridUrl);
79
49
 
80
50
  runner.on('stop', () => {
81
- void copyStatics(config.reportDir).then(() =>
82
- writeFile(path.join(config.reportDir, 'data.js'), reportDataModule(runner.status.tests)),
83
- );
51
+ void copyStatics(config.reportDir).then(() => runner.testsManager.saveTestData());
84
52
  });
85
53
 
86
54
  if (options.ui) {
87
- resolveApi(creeveyApi(runner));
55
+ // Initialize TestsManager
56
+ const testsManager = runner.testsManager;
57
+
58
+ // Create the CreeveyApi instance using the existing runner
59
+ const api = new CreeveyApi(testsManager, runner);
60
+
61
+ // Resolve the API for the server
62
+ resolveApi(api);
63
+
88
64
  logger().info(`Started on http://localhost:${options.port}`);
89
65
  } else {
90
66
  if (Object.values(runner.status.tests).filter((test) => test && !test.skip).length == 0) {
@@ -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
+ }