-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Expand file tree
/
Copy pathweb-test-runner.config.mjs
More file actions
372 lines (329 loc) · 12.4 KB
/
web-test-runner.config.mjs
File metadata and controls
372 lines (329 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
import { playwrightLauncher } from '@web/test-runner-playwright';
import path from 'path';
import fs from 'fs';
import { SourceMapConsumer } from 'source-map';
// Load source map for translating stack traces (local dev only)
let sourceMapConsumer = null;
const sourceMapPath = path.join(process.cwd(), 'dist/test.js.map');
if (fs.existsSync(sourceMapPath)) {
const sourceMapData = JSON.parse(fs.readFileSync(sourceMapPath, 'utf8'));
sourceMapConsumer = await new SourceMapConsumer(sourceMapData);
}
// Translate a stack trace using source maps
function translateStack(stack) {
if (!sourceMapConsumer || !stack) return stack;
return stack.split('\n').map(line => {
// Match stack frame pattern: "at ... (url:line:col)" or "at url:line:col"
const match = line.match(/^(\s*at\s+.*?)(?:\()?(?:https?:\/\/[^/]+)?\/dist\/test\.js[^:]*:(\d+):(\d+)\)?$/);
if (!match) return line;
const [, prefix, lineNum, colNum] = match;
const pos = sourceMapConsumer.originalPositionFor({
line: parseInt(lineNum, 10),
column: parseInt(colNum, 10)
});
if (pos.source) {
const source = pos.source.replace(/^\.\.\//, '');
return `${prefix}(${source}:${pos.line}:${pos.column})`;
}
return line;
}).join('\n');
}
// Map SAUCE_REGION env var to hostname
const sauceRegionHostnames = {
'us': 'ondemand.us-west-1.saucelabs.com',
'us-west-1': 'ondemand.us-west-1.saucelabs.com',
'us-east-4': 'ondemand.us-east-4.saucelabs.com',
'eu': 'ondemand.eu-central-1.saucelabs.com',
'eu-central-1': 'ondemand.eu-central-1.saucelabs.com',
};
// Build SauceLabs launchers if credentials are available
const sauceLabsConfig = process.env.SAUCE_ACCESS_KEY ? {
hostname: sauceRegionHostnames[process.env.SAUCE_REGION] || 'ondemand.us-west-1.saucelabs.com',
port: 443,
path: '/wd/hub',
protocol: 'https',
user: process.env.SAUCE_USERNAME,
key: process.env.SAUCE_ACCESS_KEY,
} : null;
// Dynamic import for webdriver launcher only when needed
const createSauceLabsLaunchers = async () => {
if (!sauceLabsConfig) return [];
const { webdriverLauncher } = await import('@web/test-runner-webdriver');
const { GITHUB_WORKFLOW, GITHUB_RUN_NUMBER, GITHUB_RUN_ID, SAUCE_TUNNEL_IDENTIFIER } = process.env;
const buildId = GITHUB_WORKFLOW && GITHUB_RUN_NUMBER && GITHUB_RUN_ID
? `${GITHUB_WORKFLOW} #${GITHUB_RUN_NUMBER} (${GITHUB_RUN_ID})`
: 'local';
const sauceOptions = {
name: 'Trix',
build: buildId,
// Use tunnelName for SC5 (tunnelIdentifier is SC4 legacy)
...(SAUCE_TUNNEL_IDENTIFIER && { tunnelName: SAUCE_TUNNEL_IDENTIFIER }),
};
return [
webdriverLauncher({
...sauceLabsConfig,
capabilities: {
browserName: 'chrome',
browserVersion: 'latest',
platformName: 'Windows 10',
'sauce:options': sauceOptions,
},
}),
webdriverLauncher({
...sauceLabsConfig,
capabilities: {
browserName: 'firefox',
browserVersion: 'latest',
platformName: 'Windows 10',
'moz:debuggerAddress': true, // Required for SauceLabs Firefox
'sauce:options': sauceOptions,
},
}),
webdriverLauncher({
...sauceLabsConfig,
capabilities: {
browserName: 'MicrosoftEdge',
browserVersion: 'latest',
platformName: 'Windows 10',
'sauce:options': sauceOptions,
},
}),
// Android emulator - Chrome browser
webdriverLauncher({
...sauceLabsConfig,
capabilities: {
browserName: 'chrome',
platformName: 'Android',
'appium:deviceName': 'Android GoogleAPI Emulator',
'appium:platformVersion': '14.0',
'appium:automationName': 'UiAutomator2',
'sauce:options': sauceOptions,
},
}),
];
};
// Default to Playwright Chromium for local development
const defaultBrowsers = [
playwrightLauncher({ product: 'chromium' }),
];
// Enable real-time progress reporting for local dev (single browser)
const localDev = !sauceLabsConfig && !process.env.CI;
export default {
// The test file(s)
files: ['dist/test.js'],
// Serve files from project root
rootDir: '.',
// Bind to all interfaces so Sauce Connect can reach the server
hostname: '0.0.0.0',
// Enable node module resolution for WTR core imports
nodeResolve: true,
// Browser configuration - SauceLabs if credentials available, otherwise Playwright
browsers: sauceLabsConfig ? await createSauceLabsLaunchers() : defaultBrowsers,
// Timeouts (generous for SauceLabs network latency and slow tests)
browserStartTimeout: 120000,
testsStartTimeout: 120000,
testsFinishTimeout: 600000,
// Parallel browser execution
concurrency: sauceLabsConfig ? 4 : 1,
// Use static logging for real-time progress (local dev only)
staticLogging: localDev,
// Custom reporter for local dev; undefined falls back to default for CI
reporters: localDev ? [
{
onTestRunFinished({ sessions }) {
let passed = 0, failed = 0, skipped = 0;
for (const session of sessions) {
const countTests = (suite) => {
for (const test of suite.tests || []) {
if (test.skipped) skipped++;
else if (test.passed) passed++;
else failed++;
}
for (const child of suite.suites || []) countTests(child);
};
if (session.testResults) countTests(session.testResults);
}
const total = passed + failed + skipped;
process.stdout.write(`\n\n${total} tests: ${passed} passed, ${failed} failed, ${skipped} skipped.\n\n`);
},
},
] : undefined,
// Middleware to serve test fixtures and QUnit from local files
middleware: [
// Real-time test progress reporting
async function testProgressReporter(context, next) {
if (context.method === 'POST' && context.url === '/test-progress') {
const chunks = [];
for await (const chunk of context.req) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks).toString();
const { status, name, error } = JSON.parse(body);
// Match the same logic used in the reporter: skipped, failed, or passed (everything else)
const progressIndicator = status === 'skipped' ? 'S' : status === 'failed' ? 'F' : '.';
process.stdout.write(progressIndicator);
// Print failure details immediately
if (status === 'failed') {
process.stdout.write(`\n\nFAIL: ${name}\n`);
if (error) {
if (error.message) {
process.stdout.write(` Message: ${error.message}\n`);
}
if (error.expected !== undefined) {
process.stdout.write(` Expected: ${error.expected}\n`);
}
if (error.actual !== undefined) {
process.stdout.write(` Actual: ${error.actual}\n`);
}
if (error.stack) {
const translatedStack = translateStack(error.stack);
process.stdout.write(` Stack:\n ${translatedStack.split('\n').join('\n ')}\n`);
}
}
process.stdout.write('\n');
}
context.status = 200;
context.body = 'ok';
return;
}
return next();
},
function serveLocalFiles(context, next) {
// Serve test fixtures from src/test/test_helpers/fixtures/
if (context.url.startsWith('/test_helpers/fixtures/')) {
const filePath = path.join(process.cwd(), 'src/test', context.url);
if (fs.existsSync(filePath)) {
context.body = fs.createReadStream(filePath);
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif' };
context.type = mimeTypes[ext] || 'application/octet-stream';
return;
}
}
// Serve QUnit from node_modules (avoid CDN issues with SauceLabs)
if (context.url.startsWith('/qunit/')) {
const filePath = path.join(process.cwd(), 'node_modules', context.url);
if (fs.existsSync(filePath)) {
context.body = fs.createReadStream(filePath);
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = { '.js': 'application/javascript', '.css': 'text/css' };
context.type = mimeTypes[ext] || 'application/octet-stream';
return;
}
}
return next();
},
],
// Custom HTML that sets up QUnit and bridges to WTR
// Note: first param is testFrameworkImport (mocha path by default), which we ignore
// We use getConfig() to get the actual test file path
testRunnerHtml: () => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Trix Tests</title>
<link rel="stylesheet" href="/qunit/qunit/qunit.css">
<link rel="stylesheet" href="/dist/trix.css">
<style>
#trix-container { height: 150px; }
trix-toolbar { margin-bottom: 10px; }
trix-toolbar button { border: 1px solid #ccc; background: #fff; }
trix-toolbar button.active { background: #d3e6fd; }
trix-toolbar button:disabled { color: #ccc; }
#qunit { position: relative !important; }
</style>
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<!-- 1. Load QUnit locally (classic script, runs first) -->
<script src="/qunit/qunit/qunit.js"></script>
<!-- 2. Prevent QUnit autostart before we set up hooks -->
<script>QUnit.config.autostart = false;</script>
<!-- 3. WTR integration + load tests + start QUnit -->
<script type="module">
import { getConfig, sessionStarted, sessionFinished, sessionFailed }
from '@web/test-runner-core/browser/session.js';
// Real-time progress reporting only for local dev (single browser)
const reportProgress = ${localDev};
try {
await sessionStarted();
// Get the actual test file path from WTR config
const { testFile } = await getConfig();
// Build test results structure for WTR
const testSuite = { name: testFile, tests: [], suites: [] };
const errors = [];
QUnit.on('error', (error) => {
errors.push({ message: error?.message, stack: error?.stack });
});
QUnit.on('testEnd', (result) => {
// POST progress to server for real-time output
if (reportProgress) {
const payload = { status: result.status };
if (result.status === 'failed') {
payload.name = result.fullName.join(' > ');
if (result.errors?.[0]) {
const err = result.errors[0];
payload.error = {
message: err.message || 'Assertion Error',
expected: JSON.stringify(err.expected, null, 2),
actual: JSON.stringify(err.actual, null, 2),
stack: err.stack
};
}
}
fetch('/test-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).catch(() => {});
}
// Navigate to correct suite in hierarchy
const modules = result.fullName.slice(0, -1);
let currentSuite = testSuite;
for (const name of modules) {
let suite = currentSuite.suites.find(s => s.name === name);
if (!suite) {
suite = { name, suites: [], tests: [] };
currentSuite.suites.push(suite);
}
currentSuite = suite;
}
const testResult = {
name: result.name,
passed: result.status !== 'failed',
skipped: result.status === 'skipped',
duration: result.runtime
};
if (!testResult.passed && result.errors?.[0]) {
const err = result.errors[0];
testResult.error = {
message: err.message || 'Assertion Error',
expected: JSON.stringify(err.expected, null, 2),
actual: JSON.stringify(err.actual, null, 2),
stack: err.stack
};
}
currentSuite.tests.push(testResult);
});
QUnit.on('runEnd', (results) => {
sessionFinished({
passed: results.status === 'passed',
errors,
testResults: testSuite
}).catch(console.error);
});
// Import test bundle using path from getConfig()
await import(testFile);
// Start QUnit
QUnit.start();
} catch (error) {
console.error('Test setup failed:', error);
sessionFailed({ message: error.message, stack: error.stack });
}
</script>
</body>
</html>
`,
};