@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/.travis.yml +1 -1
- package/CHANGELOG.md +10 -0
- package/config/wdio.conf.js +47 -24
- package/npm-shrinkwrap.json +1742 -1953
- package/package.json +19 -20
- package/screenshot/utils/confHelpers.js +212 -14
- package/src/build-apps.js +5 -3
- package/utils/Page.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enact/ui-test-utils",
|
|
3
|
-
"version": "4.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": "^
|
|
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.
|
|
33
|
-
"@wdio/dot-reporter": "^9.
|
|
34
|
-
"@wdio/local-runner": "^9.
|
|
35
|
-
"@wdio/mocha-framework": "^9.
|
|
36
|
-
"@wdio/spec-reporter": "^9.
|
|
37
|
-
"@wdio/static-server-service": "^9.
|
|
38
|
-
"@wdio/visual-service": "^9.
|
|
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.
|
|
42
|
-
"fs-extra": "^11.3.
|
|
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.
|
|
46
|
-
"readdirp": "^
|
|
45
|
+
"ramda": "^0.32.0",
|
|
46
|
+
"readdirp": "^5.0.0",
|
|
47
47
|
"wdio-docker-service": "^3.2.1",
|
|
48
|
-
"webdriverio": "^9.
|
|
48
|
+
"webdriverio": "^9.27.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"eslint": "^9.
|
|
52
|
-
"eslint-config-enact": "^5.0
|
|
53
|
-
"globals": "^
|
|
51
|
+
"eslint": "^9.39.3",
|
|
52
|
+
"eslint-config-enact": "^5.1.0",
|
|
53
|
+
"globals": "^17.4.0"
|
|
54
54
|
},
|
|
55
55
|
"overrides": {
|
|
56
|
-
"
|
|
57
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
13
|
+
BROWSERSLIST: 'Chrome 132'
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
function findViews (base) {
|
|
17
|
-
return
|
|
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