@enact/ui-test-utils 4.0.0 → 4.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enact/ui-test-utils",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "UI Testing for the Enact framework",
5
5
  "repository": "https://github.com/enactjs/ui-test-utils",
6
6
  "type": "module",
@@ -23,38 +23,37 @@
23
23
  "author": "Roy Sutton <roy.sutton@lge.com>",
24
24
  "license": "Apache-2.0",
25
25
  "engines": {
26
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
26
+ "node": "^20.19.0 || ^22.0.0 || >=24.0.0"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@wdio/cli": "^9.20.0",
33
- "@wdio/dot-reporter": "^9.20.0",
34
- "@wdio/local-runner": "^9.20.0",
35
- "@wdio/mocha-framework": "^9.20.0",
36
- "@wdio/spec-reporter": "^9.20.0",
37
- "@wdio/static-server-service": "^9.20.0",
38
- "@wdio/visual-service": "^9.0.0",
32
+ "@wdio/cli": "^9.27.0",
33
+ "@wdio/dot-reporter": "^9.27.0",
34
+ "@wdio/local-runner": "^9.27.0",
35
+ "@wdio/mocha-framework": "^9.27.0",
36
+ "@wdio/spec-reporter": "^9.27.0",
37
+ "@wdio/static-server-service": "^9.27.0",
38
+ "@wdio/visual-service": "^9.2.1",
39
39
  "chalk": "^5.6.2",
40
40
  "cross-spawn": "^7.0.6",
41
- "expect-webdriverio": "^5.4.3",
42
- "fs-extra": "^11.3.2",
41
+ "expect-webdriverio": "^5.6.5",
42
+ "fs-extra": "^11.3.4",
43
43
  "minimist": "^1.2.8",
44
44
  "query-string": "^7.1.3",
45
- "ramda": "^0.31.3",
46
- "readdirp": "^3.6.0",
45
+ "ramda": "^0.32.0",
46
+ "readdirp": "^5.0.0",
47
47
  "wdio-docker-service": "^3.2.1",
48
- "webdriverio": "^9.20.0"
48
+ "webdriverio": "^9.27.0"
49
49
  },
50
50
  "devDependencies": {
51
- "eslint": "^9.36.0",
52
- "eslint-config-enact": "^5.0.2",
53
- "globals": "^16.4.0"
51
+ "eslint": "^9.39.3",
52
+ "eslint-config-enact": "^5.1.0",
53
+ "globals": "^17.4.0"
54
54
  },
55
55
  "overrides": {
56
- "cross-spawn": "$cross-spawn",
57
- "express": "^4.21.2",
58
- "ws": "^8.18.0"
56
+ "serialize-javascript": "^7.0.4",
57
+ "simple-xml-to-json": "1.2.4"
59
58
  }
60
59
  }
@@ -32,6 +32,7 @@ const baselineRelativePath = 'screenshots/reference';
32
32
  const screenshotRelativePath = 'screenshots/screen';
33
33
  const baselineFolder = path.join(distPath, baselineRelativePath);
34
34
  const screenshotFolder = path.join(distPath, screenshotRelativePath);
35
+ const pendingFailuresFolder = path.join(distPath, 'pending-failures');
35
36
 
36
37
  const generateReferenceName = getScreenshotName(baselineFolder);
37
38
 
@@ -52,6 +53,15 @@ function initFile (name, content) {
52
53
  }
53
54
 
54
55
  function onPrepare () {
56
+ global.sessionFailures = new Map();
57
+ global.failedSessions = new Set();
58
+
59
+ // Clear any leftover pending-failure files from a previous run
60
+ if (fs.existsSync(pendingFailuresFolder)) {
61
+ fs.rmSync(pendingFailuresFolder, {recursive: true, force: true});
62
+ }
63
+ fs.mkdirSync(pendingFailuresFolder, {recursive: true});
64
+
55
65
  if (!fs.existsSync('tests/screenshot/dist/screenshots/reference')) {
56
66
  console.log('No reference screenshots found, creating new references!');
57
67
  }
@@ -62,7 +72,169 @@ function onPrepare () {
62
72
  return buildApps('screenshot');
63
73
  }
64
74
 
65
- function beforeTest (testData) {
75
+ /* Checks if a browser session is healthy. If not, it will attempt to recover. */
76
+ async function checkSessionHealth () {
77
+ const sessionId = browser.sessionId;
78
+
79
+ // Check if this session has been marked as dead
80
+ if (global.failedSessions && global.failedSessions.has(sessionId)) {
81
+ throw new Error('Session is marked as failed - skipping remaining tests');
82
+ }
83
+
84
+ // Skip health check if the session was just recovered
85
+ if (global.recentlyRecovered && global.recentlyRecovered.has(sessionId)) {
86
+ global.recentlyRecovered.delete(sessionId);
87
+ } else {
88
+ // Quick health check with a short timeout
89
+ try {
90
+ await Promise.race([
91
+ browser.execute(() => true),
92
+ new Promise((_, reject) =>
93
+ setTimeout(() => reject(new Error('Health check timeout')), 3000)
94
+ )
95
+ ]);
96
+
97
+ // Success - reset failure counter
98
+ if (global.sessionFailures) {
99
+ global.sessionFailures.set(sessionId, 0);
100
+ }
101
+ } catch (e) {
102
+ // Track consecutive failures
103
+ const failures = (global.sessionFailures?.get(sessionId) || 0) + 1;
104
+ if (global.sessionFailures) {
105
+ global.sessionFailures.set(sessionId, failures);
106
+ }
107
+
108
+ console.log(`Session ${sessionId} health check failed (failure ${failures}/3)`);
109
+
110
+ // Only mark as dead after 3 consecutive failures
111
+ if (failures >= 3) {
112
+ console.log(`Session ${sessionId} has failed 3 times - marking as dead`);
113
+ if (global.failedSessions) {
114
+ global.failedSessions.add(sessionId);
115
+ }
116
+ throw new Error('Session health check failed - marking as dead');
117
+ }
118
+
119
+ // Try quick recovery for the first 2 failures
120
+ console.log(`Attempting quick recovery for session ${sessionId}...`);
121
+ try {
122
+ await browser.reloadSession();
123
+ await browser.setWindowSize(1920, 1167);
124
+ console.log(`Session ${sessionId} recovered`);
125
+ } catch (recoveryError) {
126
+ console.log(`Recovery attempt failed, will retry next test`);
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ async function cleanUpSessionHealthCheck (testData, error) {
133
+ if (error) {
134
+ const isTimeout = error.message &&
135
+ (error.message.includes('timeout') ||
136
+ error.message.includes('aborted') ||
137
+ error.message.includes('HEADERS_TIMEOUT') ||
138
+ error.message.includes('ECONNREFUSED'));
139
+
140
+ if (isTimeout) {
141
+ const sessionId = browser.sessionId;
142
+
143
+ // Track consecutive failures
144
+ if (!global.sessionFailures) {
145
+ global.sessionFailures = new Map();
146
+ }
147
+
148
+ const failures = (global.sessionFailures.get(sessionId) || 0) + 1;
149
+ global.sessionFailures.set(sessionId, failures);
150
+
151
+ console.log(`Timeout #${failures} in session ${sessionId} - test: "${testData.title}"`);
152
+
153
+ // Circuit breaker: after 3 consecutive timeouts, kill the session
154
+ if (failures >= 3) {
155
+ console.log(`Session ${sessionId} has failed 3 times consecutively - marking as dead`);
156
+
157
+ if (!global.failedSessions) {
158
+ global.failedSessions = new Set();
159
+ }
160
+ global.failedSessions.add(sessionId);
161
+
162
+ // Try to clean up with very short timeout, then give up
163
+ try {
164
+ await Promise.race([
165
+ (async () => {
166
+ try {
167
+ await browser.execute(() => window.stop());
168
+ } catch (e) {
169
+ // Ignore
170
+ }
171
+ await browser.deleteSession();
172
+ })(),
173
+ new Promise((_, reject) =>
174
+ setTimeout(() => reject(new Error('Cleanup timeout')), 2000)
175
+ )
176
+ ]);
177
+ console.log('Session cleanup completed');
178
+ } catch (e) {
179
+ console.log('Session cleanup timed out - session is dead');
180
+ }
181
+
182
+ return; // Don't try to recover
183
+ }
184
+
185
+ // For first 2 failures, attempt recovery
186
+ console.log(`Attempting recovery for session ${sessionId} (attempt ${failures}/3)`);
187
+
188
+ try {
189
+ // Try light recovery with timeout
190
+ await Promise.race([
191
+ (async () => {
192
+ try {
193
+ await browser.execute(() => window.stop());
194
+ } catch (e) {
195
+ // Ignore
196
+ }
197
+ await browser.deleteSession();
198
+ await browser.reloadSession();
199
+ await browser.setWindowSize(1920, 1167);
200
+ await browser.pause(1000);
201
+ })(),
202
+ new Promise((_, reject) =>
203
+ setTimeout(() => reject(new Error('Recovery timeout')), 10000)
204
+ )
205
+ ]);
206
+
207
+ console.log('Session recovered successfully');
208
+
209
+ // Reset failure count on successful recovery
210
+ global.sessionFailures.set(sessionId, 0);
211
+
212
+ // Mark that we just recovered - skip next health check
213
+ if (!global.recentlyRecovered) {
214
+ global.recentlyRecovered = new Set();
215
+ }
216
+ global.recentlyRecovered.add(sessionId);
217
+
218
+ } catch (recoveryError) {
219
+ console.error(`Session recovery failed: ${recoveryError.message}`);
220
+ }
221
+ } else {
222
+ const sessionId = browser.sessionId;
223
+ if (global.sessionFailures && global.sessionFailures.has(sessionId)) {
224
+ global.sessionFailures.set(sessionId, 0);
225
+ }
226
+ }
227
+ } else {
228
+ const sessionId = browser.sessionId;
229
+ if (global.sessionFailures && global.sessionFailures.has(sessionId)) {
230
+ global.sessionFailures.set(sessionId, 0);
231
+ }
232
+ }
233
+ }
234
+
235
+ async function beforeTest (testData) {
236
+ await checkSessionHealth();
237
+
66
238
  // If title doesn't have a '/', it's not a screenshot test, don't save
67
239
  if (testData && testData.title && testData.title.indexOf('/') > 0) {
68
240
  const filename = generateReferenceName({test: testData});
@@ -78,7 +250,7 @@ function beforeTest (testData) {
78
250
  }
79
251
  }
80
252
 
81
- function afterTest (testData, _context, {passed}) {
253
+ async function afterTest (testData, _context, {error, passed}) {
82
254
  // If this doesn't include context data, not a screenshot test
83
255
  if (testData && testData.title && testData.context && testData.context.params) {
84
256
  const fileName = testData.context.fileName.replace(/ /g, '_') + '.png';
@@ -98,26 +270,52 @@ function afterTest (testData, _context, {passed}) {
98
270
  });
99
271
  }
100
272
 
273
+ // Use a stable filename derived from the test identifier so the worker can find and
274
+ // delete the right file on a passing retry, even across different worker processes.
275
+ const testIdentifier = testData.title + '::' + fileName;
276
+ const pendingKey = cryptoModule.createHash('md5').update(testIdentifier).digest('hex');
277
+ const pendingFile = path.join(pendingFailuresFolder, `${pendingKey}.json`);
278
+
101
279
  if (!passed) {
280
+ // Track pending failed tests to avoid duplicate logging during retries
102
281
  const screenPath = path.join(screenshotRelativePath, 'actual', fileName);
103
282
  const diffPath = path.join(screenshotRelativePath, 'diff', fileName);
104
- fs.open(failedScreenshotFilename, 'a', (err, fd) => {
105
- if (err) {
106
- console.error('Unable to create failed test log file!');
107
- } else {
108
- const title = testData.title.replace(/~\//g, '/');
109
- const {params, url} = testData.context;
110
- const output = {title, diffPath, referencePath, screenPath, params, url};
111
- fs.appendFile(fd, `${JSON.stringify(output)},`, 'utf8', () => {
112
- fs.close(fd);
113
- });
114
- }
115
- });
283
+ const title = testData.title.replace(/~\//g, '/');
284
+ const {params, url} = testData.context;
285
+ const output = {title, diffPath, referencePath, screenPath, params, url};
286
+ try {
287
+ fs.writeFileSync(pendingFile, JSON.stringify(output), 'utf8');
288
+ } catch (writeErr) {
289
+ console.error(`Unable to stage failure for "${title}": ${writeErr.message}`);
290
+ }
291
+ } else {
292
+ // Test passed (possibly on retry) — remove the staged failure so it won't be reported
293
+ try {
294
+ fs.unlinkSync(pendingFile);
295
+ } catch (e) {
296
+ // test passed on first attempt — nothing to do
297
+ }
116
298
  }
117
299
  }
300
+
301
+ await cleanUpSessionHealthCheck(testData, error);
118
302
  }
119
303
 
120
304
  function onComplete () {
305
+ // Write all tests that ultimately failed
306
+ if (fs.existsSync(pendingFailuresFolder)) {
307
+ const pendingFiles = fs.readdirSync(pendingFailuresFolder).filter(f => f.endsWith('.json'));
308
+ for (const file of pendingFiles) {
309
+ try {
310
+ const raw = fs.readFileSync(path.join(pendingFailuresFolder, file), 'utf8');
311
+ fs.appendFileSync(failedScreenshotFilename, `${raw},`, 'utf8');
312
+ } catch (e) {
313
+ console.error(`Failed to flush pending failure ${file}: ${e.message}`);
314
+ }
315
+ }
316
+ fs.rmSync(pendingFailuresFolder, {recursive: true, force: true});
317
+ }
318
+
121
319
  const {size: newSize} = fs.statSync(newScreenshotFilename),
122
320
  {size: failedSize} = fs.statSync(failedScreenshotFilename);
123
321
 
package/src/build-apps.js CHANGED
@@ -3,18 +3,20 @@ import path from 'path';
3
3
  let chalk;
4
4
  import spawn from 'cross-spawn';
5
5
  import fs from 'fs-extra';
6
- import readdirp from 'readdirp';
6
+ import {readdirpPromise} from 'readdirp';
7
7
  import * as url from 'url';
8
8
 
9
9
  const env = {
10
10
  ILIB_BASE_PATH: '/framework/ilib',
11
11
  ILIB_ASSET_CREATE: 'false',
12
12
  SIMPLE_CSS_IDENT: 'true',
13
- BROWSERSLIST: 'Chrome 79'
13
+ BROWSERSLIST: 'Chrome 132'
14
14
  };
15
15
 
16
16
  function findViews (base) {
17
- return readdirp.promise(path.join('tests', base, 'apps'), {fileFilter: '*-View.js'});
17
+ return readdirpPromise(path.join('tests', base, 'apps'), {
18
+ fileFilter: (entry) => entry.basename.endsWith('-View.js')
19
+ });
18
20
  }
19
21
 
20
22
  // eslint-disable-next-line no-shadow
package/utils/Page.js CHANGED
@@ -12,7 +12,7 @@ export class Page {
12
12
 
13
13
  async open (appPath, urlExtra = '?locale=en-US') {
14
14
  await browser.execute(function () {
15
- document.body.innerHTML = '';
15
+ document.body.textContent = '';
16
16
  });
17
17
 
18
18
  this._url = `/${appPath}/${urlExtra}`;