local-test-runner 0.0.0-stage → 0.0.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -2
  3. package/dist/index.js +1141 -0
  4. package/package.json +68 -4
package/dist/index.js ADDED
@@ -0,0 +1,1141 @@
1
+ 'use strict';
2
+
3
+ var querystring = require('querystring');
4
+ var pc = require('picocolors');
5
+ var child_process = require('child_process');
6
+ var fs = require('fs');
7
+ var os = require('os');
8
+ var path = require('path');
9
+ var events = require('events');
10
+ var picomatch = require('picomatch');
11
+ var tinyglobby = require('tinyglobby');
12
+ var connect = require('connect');
13
+ var serveStatic = require('serve-static');
14
+ var escapeHTML = require('escape-html');
15
+ var MagicString = require('magic-string');
16
+ var parse5 = require('parse5');
17
+ var HttpProxy = require('http-proxy');
18
+
19
+ // 按 ?ie=X 注入 X-UA-Compatible,配合 EdgeIEMode preset(msedge --ie-mode-test)
20
+ // 让页面在指定 IE 文档模式下渲染。注意必须用 http-equiv,name 会被 IE 忽略。
21
+ var EmulateIE = {
22
+ transformHtml(html, ctx) {
23
+ var params = querystring.parse(ctx.filename.split('?')[1] || '');
24
+ if (params.ie) {
25
+ return [{
26
+ injectTo: 'head-prepend',
27
+ tag: 'meta',
28
+ attrs: {
29
+ 'http-equiv': 'X-UA-Compatible',
30
+ content: `IE=${params.ie}`
31
+ }
32
+ }];
33
+ }
34
+ }
35
+ };
36
+
37
+ /**
38
+ * 把 MultiTestResult 归一化为扁平的 settled 结果数组,支持任意层级:
39
+ * - 数组 → 逐项递归展开
40
+ * - settled 对象 → fulfilled 递归解包 value(可能是 TestResult、数组或再嵌套的 settled),
41
+ * rejected 原样保留(对应被拒绝的任务或整个 runQueue)
42
+ * - 普通 TestResult(无 status)→ 包装成 { status: "fulfilled", value }
43
+ */
44
+ function flattenResults$1(input) {
45
+ if (Array.isArray(input)) {
46
+ return input.reduce(function (acc, item) {
47
+ return acc.concat(flattenResults$1(item));
48
+ }, []);
49
+ }
50
+ const node = input;
51
+ if (node && (node.status === "fulfilled" || node.status === "rejected")) {
52
+ if (node.status === "fulfilled") {
53
+ return flattenResults$1(node.value);
54
+ }
55
+ return [node];
56
+ }
57
+ return [{ status: "fulfilled", value: node }];
58
+ }
59
+ /**
60
+ * 裁断是否通过:没有任何任务被拒绝、且每个已完成任务的用例全部通过(且至少跑了 1 条)时为 true。
61
+ *
62
+ * 用 some + 取反,而不是 every:
63
+ * - some 在空数组上返回 false,取反后为 true,空队列视为通过(不跑 = 不失败);
64
+ * - 语义上即"不存在失败项",比"所有项都通过"更贴合"不中断队列"的整体判定。
65
+ *
66
+ * 入支持嵌套数组(如多次 runQueue 的结果合并),内部会先扁平化。
67
+ */
68
+ function judge(results) {
69
+ var list = flattenResults$1(results);
70
+ return !list.some(function (settled) {
71
+ if (settled.status !== "fulfilled") {
72
+ return true;
73
+ }
74
+ return settled.value.total === 0 || settled.value.passed !== settled.value.total;
75
+ });
76
+ }
77
+
78
+ // 注入到测试页的结果上报脚本。
79
+ // 除 total/passed/runtime 外,还收集失败断言明细(测试名/消息/期望/实际),
80
+ // 以 fail0..failN 字段一并 POST 回服务器。
81
+ // 保持 ES3 兼容:不用 JSON、不用 ES5,IE6+ / Firefox1.5+ / 老 Presto Opera 可用。
82
+ const apiScript$1 = `
83
+ (function(){
84
+ var fails = [];
85
+ var current = '';
86
+ function fmt(v){
87
+ var t;
88
+ try { t = String(v); } catch(e) { t = '?'; }
89
+ if(t.length > 300) { t = t.substring(0, 300) + '...'; }
90
+ return t;
91
+ }
92
+ function onTestStart(t){ current = (t && t.name) || ''; }
93
+ function onLog(d){
94
+ if(d && d.result === false){
95
+ fails.push(current + ' :: ' + fmt(d.message) + ' (expected: ' + fmt(d.expected) + ', actual: ' + fmt(d.actual) + ')');
96
+ }
97
+ }
98
+ function finish(result){
99
+ setTimeout(function(){
100
+ var form = document.createElement('form');
101
+ form.method = 'POST';
102
+ form.action = '/';
103
+ form.style.display = 'none';
104
+ function add(name, value){
105
+ var input = document.createElement('input');
106
+ input.name = name;
107
+ input.value = value;
108
+ form.appendChild(input);
109
+ }
110
+ add('total', result.total);
111
+ add('passed', result.passed);
112
+ add('runtime', result.runtime);
113
+ add('failures', fails.length);
114
+ for(var i = 0; i < fails.length && i < 50; i++){ add('fail' + i, fails[i]); }
115
+ document.body.appendChild(form);
116
+ form.submit();
117
+ }, 500);
118
+ }
119
+ // QUnit 2.x 有 QUnit.version,支持 QUnit.done(callback) 注册;
120
+ // QUnit 1.x 没有 version,且它的 runLoggingCallbacks 会优先直接调用 QUnit.done/log/testStart,
121
+ // 注册式回调根本不会被遍历到,只能覆盖赋值。
122
+ if(QUnit.version){
123
+ QUnit.testStart(onTestStart);
124
+ QUnit.log(onLog);
125
+ QUnit.done(finish);
126
+ } else {
127
+ QUnit.testStart = onTestStart;
128
+ QUnit.log = onLog;
129
+ QUnit.done = finish;
130
+ }
131
+ })();
132
+ `;
133
+ var QUnit = {
134
+ transformHtml() {
135
+ return [{
136
+ injectTo: 'body',
137
+ tag: 'script',
138
+ children: apiScript$1
139
+ }];
140
+ }
141
+ };
142
+
143
+ // 注入到测试页的结果上报脚本。
144
+ // 监听 Mocha Runner 的 fail / end 事件,把 total / passed / runtime 与失败明细
145
+ // 以 fail0..failN 字段 POST 回服务器。
146
+ // 保持 ES3 兼容:不用 JSON、不用 ES5,IE6+ / Firefox1.5+ / 老 Presto Opera 可用。
147
+ const apiScript = `
148
+ (function(){
149
+ var fails = [];
150
+ var startTime = 0;
151
+ function nowTime(){ return new Date().getTime(); }
152
+ function fmt(v){
153
+ var t;
154
+ try { t = String(v); } catch(e) { t = '?'; }
155
+ if(t.length > 300) { t = t.substring(0, 300) + '...'; }
156
+ return t;
157
+ }
158
+ function testTitle(test){
159
+ var title = '';
160
+ try {
161
+ if(test){
162
+ if(typeof test.fullTitle === 'function'){ title = test.fullTitle(); }
163
+ else if(test.title){ title = test.title; }
164
+ }
165
+ } catch(e) {}
166
+ return title || '';
167
+ }
168
+ function onFail(test, err){
169
+ var msg = '';
170
+ try { msg = (err && (err.message || String(err))) || ''; } catch(e) {}
171
+ fails.push(testTitle(test) + ' :: ' + fmt(msg));
172
+ }
173
+ function finish(total, passed, runtime){
174
+ setTimeout(function(){
175
+ var form = document.createElement('form');
176
+ form.method = 'POST';
177
+ form.action = '/';
178
+ form.style.display = 'none';
179
+ function add(name, value){
180
+ var input = document.createElement('input');
181
+ input.name = name;
182
+ input.value = value;
183
+ form.appendChild(input);
184
+ }
185
+ add('total', total);
186
+ add('passed', passed);
187
+ add('runtime', runtime);
188
+ add('failures', fails.length);
189
+ for(var i = 0; i < fails.length && i < 50; i++){ add('fail' + i, fails[i]); }
190
+ document.body.appendChild(form);
191
+ form.submit();
192
+ }, 500);
193
+ }
194
+ function attach(runner){
195
+ if(!runner || runner.__localTestRunnerAttached){ return; }
196
+ runner.__localTestRunnerAttached = true;
197
+ var tests = 0;
198
+ var passes = 0;
199
+ runner.on('test end', function(){ tests++; });
200
+ runner.on('pass', function(){ passes++; });
201
+ runner.on('fail', onFail);
202
+ runner.on('end', function(){
203
+ var stats = runner.stats || {};
204
+ var total = stats.tests != null ? stats.tests : tests;
205
+ var passed = stats.passes != null ? stats.passes : passes;
206
+ var runtime = stats.duration;
207
+ if(runtime == null){ runtime = startTime ? (nowTime() - startTime) : 0; }
208
+ finish(total, passed, runtime);
209
+ });
210
+ }
211
+ // 直接劫持 Runner.prototype.run:Mocha 的同步测试会在 run() 返回前全部跑完,
212
+ // 只有在这里提前挂事件,才能拿到完整的 fail / end。
213
+ var Runner = window.Mocha && window.Mocha.Runner;
214
+ if(Runner && Runner.prototype && typeof Runner.prototype.run === 'function' && !Runner.prototype.__localTestRunnerHooked){
215
+ var origRun = Runner.prototype.run;
216
+ Runner.prototype.run = function(){
217
+ if(!startTime){ startTime = nowTime(); }
218
+ attach(this);
219
+ return origRun.apply(this, arguments);
220
+ };
221
+ Runner.prototype.__localTestRunnerHooked = true;
222
+ } else if(window.mocha && typeof mocha.run === 'function'){
223
+ // 兜底:无法劫持 Runner 时,只能捕获 run() 返回之后的异步事件。
224
+ var origMochaRun = mocha.run;
225
+ mocha.run = function(){
226
+ startTime = nowTime();
227
+ var runner = origMochaRun.apply(mocha, arguments);
228
+ attach(runner);
229
+ return runner;
230
+ };
231
+ }
232
+ })();
233
+ `;
234
+ var Mocha = {
235
+ transformHtml() {
236
+ return [{
237
+ injectTo: 'body',
238
+ tag: 'script',
239
+ children: apiScript
240
+ }];
241
+ }
242
+ };
243
+
244
+ /**
245
+ * 把 MultiTestResult 归一化为扁平的 settled 结果数组,支持任意层级:
246
+ * - 数组 → 逐项递归展开
247
+ * - settled 对象 → fulfilled 递归解包 value(可能是 TestResult、数组或再嵌套的 settled),
248
+ * rejected 原样保留(对应被拒绝的任务或整个 runQueue)
249
+ * - 普通 TestResult(无 status)→ 包装成 { status: "fulfilled", value }
250
+ */
251
+ function flattenResults(input) {
252
+ if (Array.isArray(input)) {
253
+ return input.reduce(function (acc, item) {
254
+ return acc.concat(flattenResults(item));
255
+ }, []);
256
+ }
257
+ const node = input;
258
+ if (node && (node.status === "fulfilled" || node.status === "rejected")) {
259
+ if (node.status === "fulfilled") {
260
+ return flattenResults(node.value);
261
+ }
262
+ return [node];
263
+ }
264
+ return [{ status: "fulfilled", value: node }];
265
+ }
266
+ /**
267
+ * 输出测试结果:逐行汇总每个任务的通过情况与失败明细,末尾输出总计。
268
+ * results 为 MultiTestResult(单个 TestResult、runQueue 的 settled 数组、并行 allSettled 结果等),
269
+ * 内部统一扁平化;tasks 与扁平化后的结果按下标一一对应,用于显示浏览器名称/版本,缺省时以 Task#N 标识。
270
+ * 返回统计对象供程序化使用。
271
+ */
272
+ function report(results, tasks) {
273
+ var list = flattenResults(results);
274
+ const summary = {
275
+ tasks: list.length,
276
+ tasksPassed: 0,
277
+ tasksFailed: 0,
278
+ tasksErrored: 0,
279
+ passed: 0,
280
+ failed: 0,
281
+ tests: 0,
282
+ runtime: 0
283
+ };
284
+ if (!list.length) {
285
+ console.log(pc.yellow("No test results."));
286
+ return summary;
287
+ }
288
+ const lines = [];
289
+ list.forEach(function (settled, index) {
290
+ const task = tasks && tasks[index];
291
+ const label = ((task && (task.browser || task.preset)) || "Task#" + (index + 1))
292
+ + (task && task.version ? " v" + task.version : "");
293
+ if (settled.status === "fulfilled") {
294
+ const result = settled.value;
295
+ summary.passed += result.passed;
296
+ summary.tests += result.total;
297
+ summary.failed += result.total - result.passed;
298
+ summary.runtime += result.runtime || 0;
299
+ if (result.total === 0) {
300
+ summary.tasksFailed++;
301
+ lines.push(pc.yellow("⚠") + " " + pc.yellow(pc.bold(label + ":")) + " " + pc.yellow("0 tests executed"));
302
+ }
303
+ else if (result.passed === result.total) {
304
+ summary.tasksPassed++;
305
+ lines.push(pc.green("✓") + " " +
306
+ pc.green(pc.bold(label + ":")) + " " +
307
+ pc.green(result.passed + "/" + result.total + " passed") + " " +
308
+ pc.dim("(" + result.runtime + "ms)"));
309
+ }
310
+ else {
311
+ summary.tasksFailed++;
312
+ lines.push(pc.red("✗") + " " +
313
+ pc.red(pc.bold(label + ":")) + " " +
314
+ pc.red(result.passed + "/" + result.total + " failed") + " " +
315
+ pc.dim("(" + result.runtime + "ms)"));
316
+ }
317
+ appendFailureDetails(lines, result);
318
+ }
319
+ else {
320
+ summary.tasksErrored++;
321
+ summary.tasksFailed++;
322
+ const reason = (settled.reason && settled.reason.message) || String(settled.reason);
323
+ lines.push(pc.red("✗") + " " + pc.red(pc.bold(label + ":")) + " " + pc.red(String(reason)));
324
+ }
325
+ });
326
+ console.log(lines.join("\n"));
327
+ console.log(pc.dim("-".repeat(60)));
328
+ console.log("Total: " + summary.tasks + " tasks, " +
329
+ summary.tasksPassed + " passed, " +
330
+ summary.tasksFailed + " failed" +
331
+ (summary.tasksErrored ? " (" + summary.tasksErrored + " errors)" : "") + ", " +
332
+ summary.passed + "/" + summary.tests + " tests, " +
333
+ summary.runtime + "ms");
334
+ return summary;
335
+ }
336
+ function appendFailureDetails(lines, result) {
337
+ const details = result.failureDetails || [];
338
+ for (let i = 0; i < details.length; i++) {
339
+ lines.push(pc.red(" ✗ " + details[i]));
340
+ }
341
+ if (result.failures && result.failures > details.length) {
342
+ lines.push(pc.red(" ... and " + (result.failures - details.length) + " more"));
343
+ }
344
+ }
345
+
346
+ const testStartEmitter = new events.EventEmitter();
347
+ const testEndEmitter = new events.EventEmitter();
348
+ var TaskStatus;
349
+ (function (TaskStatus) {
350
+ TaskStatus[TaskStatus["Waiting"] = 0] = "Waiting";
351
+ TaskStatus[TaskStatus["Running"] = 1] = "Running";
352
+ TaskStatus[TaskStatus["Finished"] = 2] = "Finished";
353
+ })(TaskStatus || (TaskStatus = {}));
354
+
355
+ // Edge 的固定安装路径(按常见位置探测,找不到时退回 PATH 里的 msedge)
356
+ const EDGE_PATH = [
357
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
358
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
359
+ ].find(function (p) {
360
+ try {
361
+ return fs.statSync(p).isFile();
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }) || "msedge";
367
+ function genPresetOptions(preset, version) {
368
+ switch (preset) {
369
+ case "Chrome":
370
+ return {
371
+ command: "chrome",
372
+ flags: [
373
+ '--user-data-dir=' + path.resolve(os.tmpdir(), "test-chrome-" + Date.now()),
374
+ // 一次性空缓存目录:no-store 响应头救不了已存在的缓存条目,
375
+ // 空缓存启动才能保证每次拿到的都是服务器上的最新产物
376
+ '--disk-cache-dir=' + path.resolve(os.tmpdir(), "test-chrome-cache-" + Date.now()),
377
+ '--enable-automation',
378
+ '--no-default-browser-check',
379
+ '--no-first-run',
380
+ '--disable-default-apps',
381
+ '--disable-popup-blocking',
382
+ '--disable-translate',
383
+ '--disable-background-timer-throttling',
384
+ '--disable-renderer-backgrounding',
385
+ '--disable-device-discovery-notifications'
386
+ ]
387
+ };
388
+ case "Firefox":
389
+ const tempProfileDir = path.resolve(os.tmpdir(), "test-firefox-" + Date.now());
390
+ fs.mkdirSync(tempProfileDir, { recursive: true });
391
+ const userJsContent = `
392
+ // ===== 核心防干扰配置 =====
393
+ user_pref("browser.shell.checkDefaultBrowser", false);
394
+ user_pref("browser.bookmarks.restore_default_bookmarks", false);
395
+ user_pref("browser.startup.homepage_override.mstone", "ignore");
396
+ user_pref("browser.sessionstore.resume_from_crash", false);
397
+ user_pref("dom.disable_open_during_load", false);
398
+ user_pref("dom.max_script_run_time", 0); // 防止脚本执行慢被强杀
399
+ user_pref("dom.max_chrome_script_run_time", 0);
400
+
401
+ // ===== 直连,不走系统代理(测试服务器在 127.0.0.1) =====
402
+ user_pref("network.proxy.type", 0);
403
+
404
+ // ===== 屏蔽所有更新 =====
405
+ user_pref("app.update.enabled", false);
406
+ user_pref("app.update.auto", false);
407
+ user_pref("app.update.mode", 0);
408
+ user_pref("app.update.service.enabled", false);
409
+ user_pref("extensions.update.enabled", false);
410
+ user_pref("extensions.update.autoUpdateDefault", false);
411
+ user_pref("extensions.getAddons.showPane", false);
412
+ user_pref("extensions.checkCompatibility", false);
413
+
414
+ // ===== 屏蔽遥测与安全拦截 =====
415
+ user_pref("datareporting.healthreport.service.enabled", false);
416
+ user_pref("datareporting.healthreport.uploadEnabled", false);
417
+ user_pref("datareporting.policy.dataSubmissionEnabled", false);
418
+ user_pref("toolkit.telemetry.enabled", false);
419
+ user_pref("browser.safebrowsing.enabled", false);
420
+ user_pref("browser.safebrowsing.malware.enabled", false);
421
+ `;
422
+ // 写入 user.js
423
+ fs.writeFileSync(path.join(tempProfileDir, 'user.js'), userJsContent);
424
+ return {
425
+ command: "firefox",
426
+ flags: [
427
+ // -profile 与路径必须拆成两个参数,合在一起会被当成单个 argv
428
+ '-no-remote',
429
+ '-profile',
430
+ tempProfileDir
431
+ ]
432
+ };
433
+ case "Edge":
434
+ return {
435
+ command: EDGE_PATH,
436
+ flags: [
437
+ '--user-data-dir=' + path.resolve(os.tmpdir(), "test-edge-" + Date.now()),
438
+ ]
439
+ };
440
+ case "EdgeIEMode":
441
+ return {
442
+ command: EDGE_PATH,
443
+ flags: [
444
+ '--user-data-dir=' + path.resolve(os.tmpdir(), "test-ie-" + Date.now()),
445
+ '--ie-mode-test'
446
+ ]
447
+ };
448
+ case "Opera": {
449
+ const v = parseFloat(version || '99');
450
+ if (v < 12.6) {
451
+ return {
452
+ command: "opera"
453
+ };
454
+ }
455
+ if (v < 13) {
456
+ // Opera Classic(12.60 < Presto,< v13)需要在 -pd 指定的 profile 目录下写 operaprefs.ini,
457
+ // 否则首次启动会弹出初始化许可对话框阻塞在前台,导致加载超时。
458
+ /**
459
+ * Opera Classic(Presto 内核,v12 及以下)的 operaprefs.ini。
460
+ * 作用:关闭所有初始化弹窗、设置启动类型为空白页、接受许可协议,
461
+ * 让浏览器在自动启动时不阻塞在首次启动对话框上。
462
+ * 内容来源:karma-opera-launcher 的 config/prefs.ini。
463
+ */
464
+ const OPERA_CLASSIC_PREFS = [
465
+ 'Opera Preferences version 2.1',
466
+ '',
467
+ '[User Prefs]',
468
+ 'Show Default Browser Dialog = 0',
469
+ 'Startup Type = 4',
470
+ 'Home URL = about:blank',
471
+ 'Show Close All But Active Dialog = 0',
472
+ 'Show Close All Dialog = 0',
473
+ 'Show Crash Log Upload Dialog = 0',
474
+ 'Show Delete Mail Dialog = 0',
475
+ 'Show Download Manager Selection Dialog = 0',
476
+ 'Show Geolocation License Dialog = 0',
477
+ 'Show Mail Error Dialog = 0',
478
+ 'Show New Opera Dialog = 0',
479
+ 'Show Problem Dialog = 0',
480
+ 'Show Progress Dialog = 0',
481
+ 'Show Validation Dialog = 0',
482
+ 'Show Widget Debug Info Dialog = 0',
483
+ 'Show Startup Dialog = 0',
484
+ 'Show E-mail Client = 0',
485
+ 'Show Mail Header Toolbar = 0',
486
+ 'Show Setupdialog On Start = 0',
487
+ 'Ask For Usage Stats Percentage = 0',
488
+ 'Enable Usage Statistics = 0',
489
+ 'Disable Opera Package AutoUpdate = 1',
490
+ 'Browser JavaScript = 0',
491
+ '',
492
+ '[Install]',
493
+ 'Newest Used Version = 1.00.0000',
494
+ '',
495
+ '[State]',
496
+ 'Accept License = 1',
497
+ 'Run = 0',
498
+ ].join('\n');
499
+ // Opera Classic(Presto 内核,≤ v12):用 -pd 指定 profile 目录,-nomail 禁邮件客户端。
500
+ // 与 Chromium 风格的 --user-data-dir 完全不同。
501
+ const profileDir = path.resolve(os.tmpdir(), "test-opera-" + Date.now());
502
+ fs.mkdirSync(profileDir, { recursive: true });
503
+ fs.writeFileSync(path.join(profileDir, 'operaprefs.ini'), OPERA_CLASSIC_PREFS, 'utf8');
504
+ return {
505
+ command: "opera",
506
+ flags: [
507
+ '-pd', profileDir,
508
+ '-nomail'
509
+ ]
510
+ };
511
+ }
512
+ // Opera(Chromium 内核,≥ v15)
513
+ return {
514
+ command: "opera",
515
+ flags: [
516
+ '--user-data-dir=' + path.resolve(os.tmpdir(), "test-opera-" + Date.now()),
517
+ '--no-default-browser-check',
518
+ '--no-first-run',
519
+ '--disable-default-apps'
520
+ ]
521
+ };
522
+ }
523
+ default:
524
+ return {};
525
+ }
526
+ }
527
+ function killBrowser(child, command) {
528
+ try {
529
+ if (process.platform === 'win32') {
530
+ try {
531
+ child_process.execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'ignore' });
532
+ }
533
+ catch (e) {
534
+ if (command.includes('\\')) {
535
+ const psCommand = `Get-Process firefox | Where-Object { $_.Path -eq '${command}' } | Stop-Process -Force`;
536
+ console.log(`powershell -Command "${psCommand}"`);
537
+ child_process.execSync(`powershell -Command "${psCommand}"`, { stdio: 'ignore' });
538
+ console.log(`已成功杀掉路径为 ${command} 的 Firefox`);
539
+ }
540
+ else {
541
+ child_process.execSync(`taskkill /im ${command} /T /F`, { stdio: 'ignore' });
542
+ }
543
+ }
544
+ }
545
+ else {
546
+ child.kill('SIGKILL');
547
+ }
548
+ }
549
+ catch (e) {
550
+ // taskkill 找不到进程 = 进程已经自己死了 = 不需要清理
551
+ // 这不是错误,这是好事!静默忽略即可。
552
+ console.log('进程已自行退出,无需清理');
553
+ }
554
+ }
555
+ /**
556
+ * 激活浏览器窗口:并行跑多个浏览器时,部分浏览器弹出后在后台不加载页面,
557
+ * 手动激活才会开始加载(Windows 前台锁机制),导致启动超时。
558
+ * 启动后延时激活一次窗口可避免。
559
+ */
560
+ function activateWindow(child) {
561
+ if (process.platform !== 'win32' || !child.pid)
562
+ return;
563
+ setTimeout(() => {
564
+ try {
565
+ child_process.exec(`powershell -NoProfile -Command "(New-Object -ComObject WScript.Shell).AppActivate(${child.pid})"`, { stdio: 'ignore', windowsHide: true });
566
+ }
567
+ catch (e) { /* 激活失败不影响测试,超时兜底 */ }
568
+ }, 2000);
569
+ }
570
+ /**
571
+ * 清除老版本 Opera(< 12.6)上次的会话记录与"未正常退出"状态。
572
+ *
573
+ * 这些版本不支持 -pd(参数会被当成 URL 打开成标签页),没法用临时 profile 隔离;
574
+ * 浏览器被强杀后 profile 里会留下两处痕迹,导致下次启动弹窗询问是否从上次会话继续:
575
+ * 1. sessions/autosave.win(崩溃恢复会话)—— 改名备份即可
576
+ * 2. operaprefs.ini 里的 [State] Run=1(标记上次未正常退出)—— 重置为 0
577
+ * 启动前清理这两处,下次启动就不会再询问,也不会恢复上次的标签页。
578
+ */
579
+ function clearOperaSessionRecords(version, command) {
580
+ const appData = process.env.APPDATA;
581
+ const roots = [];
582
+ if (appData && version) {
583
+ roots.push(path.join(appData, 'Opera', version), // 10.x–12.x
584
+ path.join(appData, 'Opera', version, 'profile') // 9.x
585
+ );
586
+ }
587
+ // Multi User=0 的安装(如 12.17)把 profile 放在安装目录下,不受 -pd 控制
588
+ if (command && path.isAbsolute(command)) {
589
+ const installDir = path.dirname(command);
590
+ roots.push(path.join(installDir, 'profile'), installDir);
591
+ }
592
+ for (const root of roots) {
593
+ const sessions = path.join(root, 'sessions');
594
+ for (const name of ['autosave.win', 'autosave.win.bak']) {
595
+ const file = path.join(sessions, name);
596
+ try {
597
+ fs.unlinkSync(file + '.cleared');
598
+ }
599
+ catch {
600
+ // 上一次的备份不存在,忽略
601
+ }
602
+ try {
603
+ fs.renameSync(file, file + '.cleared');
604
+ }
605
+ catch {
606
+ // 文件不存在(从未异常退出)或被占用时忽略
607
+ }
608
+ }
609
+ // 9.x 的配置文件叫 opera6.ini,10.x–12.x 才叫 operaprefs.ini
610
+ for (const name of ['operaprefs.ini', 'opera6.ini']) {
611
+ const prefs = path.join(root, name);
612
+ try {
613
+ const text = fs.readFileSync(prefs, 'utf8');
614
+ const fixed = text.replace(/^Run=1\r?$/m, 'Run=0');
615
+ if (fixed !== text)
616
+ fs.writeFileSync(prefs, fixed, 'utf8');
617
+ }
618
+ catch {
619
+ // prefs 读取失败时不影响启动
620
+ }
621
+ }
622
+ }
623
+ }
624
+ function run(options) {
625
+ var url, command, child;
626
+ var timeout;
627
+ return new Promise(function (resolve, reject) {
628
+ let resolvedOptions = {};
629
+ if (options.preset) {
630
+ let presetOptions = genPresetOptions(options.preset, options.version);
631
+ resolvedOptions = {
632
+ ...presetOptions,
633
+ ...options,
634
+ };
635
+ if (options.flags && presetOptions.flags) {
636
+ resolvedOptions.flags = [...presetOptions.flags, ...options.flags];
637
+ }
638
+ }
639
+ else {
640
+ resolvedOptions = options;
641
+ }
642
+ let { flags } = resolvedOptions;
643
+ url = resolvedOptions.url;
644
+ command = resolvedOptions.command;
645
+ timeout = resolvedOptions.timeout || 10000;
646
+ // Opera(含 -pd 被忽略的单用户安装,如 12.17)启动前清理上次未正常退出留下的
647
+ // 会话与 Run=1 状态,否则会弹出"继续上次会话"/欢迎对话框,阻塞页面加载
648
+ if (options.preset === 'Opera') {
649
+ clearOperaSessionRecords(options.version, command);
650
+ }
651
+ // 老版本 Opera 会把 URL 之后的参数当成额外 URL 各开一个标签页,
652
+ // 所以 flags 必须放在 URL 前面(例如 `opera -pd <dir> -nomail <url>`)
653
+ let args = [];
654
+ if (flags)
655
+ args.push(...flags);
656
+ args.push(url);
657
+ child = child_process.spawn(command, args);
658
+ // 后台窗口可能不加载页面,延时激活一次
659
+ activateWindow(child);
660
+ let startTimer = setTimeout(() => {
661
+ // 超时的浏览器也要杀掉,否则残留进程会一直占住父进程的事件循环
662
+ killBrowser(child, command);
663
+ reject(new Error("Browser run timed out on: " + command));
664
+ }, timeout);
665
+ testStartEmitter.once(url, function () {
666
+ clearTimeout(startTimer);
667
+ resolve();
668
+ });
669
+ }).then(function () {
670
+ return new Promise(function (resolve, reject) {
671
+ let runTimer = setTimeout(() => {
672
+ killBrowser(child, command);
673
+ reject(new Error("Test run timed out on: " + url));
674
+ }, timeout);
675
+ testEndEmitter.once(url, function (data) {
676
+ clearTimeout(runTimer);
677
+ var result = toTestResult(data);
678
+ resolve(result);
679
+ logTestResult(options, result);
680
+ killBrowser(child, command);
681
+ });
682
+ });
683
+ });
684
+ }
685
+ function toTestResult(data) {
686
+ // 表单 POST 上来的都是字符串,统一转成数字,
687
+ // 防止上层 report 求和/比较时变成字符串拼接
688
+ var result = {
689
+ passed: Number(data.passed) || 0,
690
+ total: Number(data.total) || 0,
691
+ runtime: Number(data.runtime) || 0
692
+ };
693
+ if (data.failures != null) {
694
+ result.failures = Number(data.failures) || 0;
695
+ var details = [];
696
+ for (var i = 0; i < result.failures && i < 50; i++) {
697
+ var detail = data["fail" + i];
698
+ if (detail != null)
699
+ details.push(String(detail));
700
+ }
701
+ if (details.length)
702
+ result.failureDetails = details;
703
+ }
704
+ return result;
705
+ }
706
+ function logTestResult(options, result) {
707
+ const browser = options.browser || options.preset || 'Unknown';
708
+ const version = options.version || '';
709
+ const failed = result.total - result.passed;
710
+ const isSuccess = failed === 0;
711
+ const timeStr = `${result.runtime}ms`;
712
+ if (isSuccess) {
713
+ // 🟢 全部通过:绿色高亮,粗体强调通过数
714
+ console.log(pc.green('✓') + ' ' +
715
+ pc.green(pc.bold(`${browser} v${version}:`)) + ' ' +
716
+ pc.green(`${result.passed}/${result.total} passed`) + ' ' +
717
+ pc.dim(`(${timeStr})`));
718
+ }
719
+ else {
720
+ // 🔴 存在失败:红色高亮,粗体强调失败数
721
+ console.log(pc.red('✗') + ' ' +
722
+ pc.red(pc.bold(`${browser} v${version}:`)) + ' ' +
723
+ pc.red(`${result.passed}/${result.total} failed`) + ' ' +
724
+ pc.dim(`(${timeStr})`));
725
+ }
726
+ }
727
+
728
+ function runQueue(tasks) {
729
+ if (tasks.length) {
730
+ let [task, ...rest] = tasks;
731
+ return new Promise((resolve) => {
732
+ setTimeout(() => {
733
+ resolve();
734
+ }, 200);
735
+ }).then(() => {
736
+ return runNext(task, rest);
737
+ });
738
+ }
739
+ else {
740
+ return Promise.resolve([]);
741
+ }
742
+ }
743
+ function runNext(task, tasks) {
744
+ var r;
745
+ return run(task).then(function (result) {
746
+ r = { status: "fulfilled", value: result };
747
+ return runQueue(tasks);
748
+ }, function (err) {
749
+ // 单个任务失败(浏览器启动失败/超时)不中断队列,
750
+ // 错误以 { status: "rejected", reason } 记入结果,继续后面的任务
751
+ r = { status: "rejected", reason: err };
752
+ return runQueue(tasks);
753
+ }).then(function (results) {
754
+ results.unshift(r);
755
+ return results;
756
+ });
757
+ }
758
+
759
+ async function scan(pattern, options) {
760
+ let paths = await tinyglobby.glob(pattern, {
761
+ cwd: options.cwd || process.cwd(),
762
+ absolute: true,
763
+ caseSensitiveMatch: true,
764
+ });
765
+ let regex = picomatch.makeRe(pattern, { capture: true, nocase: false });
766
+ let results = paths.map(fake => {
767
+ let result = fake.match(regex);
768
+ let [real, ...args] = result;
769
+ real = fs.realpathSync.native(real);
770
+ return [real, ...args];
771
+ });
772
+ if (options.filter) {
773
+ results = results.filter(function (result, index) {
774
+ return options.filter.apply(null, result);
775
+ });
776
+ }
777
+ let tasks = results.map(function (result, index) {
778
+ return options.map.apply(null, result);
779
+ });
780
+ return tasks;
781
+ }
782
+
783
+ function callMiddleware() {
784
+ return function (req, res, next) {
785
+ if (req.method === "GET") {
786
+ const protocol = req.socket?.encrypted ? 'https' : 'http';
787
+ const host = req.headers.host;
788
+ const fullUrl = `${protocol}://${host}${req.originalUrl}`;
789
+ testStartEmitter.emit(fullUrl);
790
+ }
791
+ else if (req.method === "POST") {
792
+ if (req.url === "/") {
793
+ let body = '';
794
+ req.on('data', (chunk) => {
795
+ body += chunk.toString();
796
+ });
797
+ req.on('end', () => {
798
+ // 保持原始 querystring 对象直接下发;
799
+ // 字段归一化(数字转换 + failN → failureDetails)统一在 run() 的 toTestResult 里做
800
+ const data = querystring.parse(body);
801
+ let referer = req.headers.referer;
802
+ res.statusCode = 200;
803
+ res.setHeader('Content-Type', 'text/html');
804
+ res.end('<html><body><script>window.close()</script><body></html>');
805
+ setTimeout(() => {
806
+ testEndEmitter.emit(referer, data);
807
+ }, 1000);
808
+ });
809
+ return;
810
+ }
811
+ }
812
+ next();
813
+ };
814
+ }
815
+
816
+ const unaryTags = new Set(['link', 'meta', 'base', 'br', 'hr', 'input', 'img']);
817
+ function serializeTags(tags) {
818
+ return tags.map((tag) => `${serializeTag(tag)}`).join('');
819
+ }
820
+ function serializeTag({ tag, attrs, children }) {
821
+ if (unaryTags.has(tag)) {
822
+ return `<${tag}${serializeAttrs(attrs)}>`;
823
+ }
824
+ else {
825
+ if (Array.isArray(children)) {
826
+ return `<${tag}${serializeAttrs(attrs)}>${serializeTags(children)}</${tag}>`;
827
+ }
828
+ else if (children) {
829
+ return `<${tag}${serializeAttrs(attrs)}>${children}</${tag}>`;
830
+ }
831
+ else {
832
+ return `<${tag}${serializeAttrs(attrs)}></${tag}>`;
833
+ }
834
+ }
835
+ }
836
+ function serializeAttrs(attrs) {
837
+ let res = '';
838
+ for (const key in attrs) {
839
+ if (typeof attrs[key] === 'boolean') {
840
+ res += attrs[key] ? ` ${key}` : ``;
841
+ }
842
+ else {
843
+ res += ` ${escapeHTML(key)}="${escapeHTML(attrs[key])}"`;
844
+ }
845
+ }
846
+ return res;
847
+ }
848
+ function insertHtmlNode(html, tags) {
849
+ // 处理入口(html)
850
+ var dom = parse5.parse(html, { sourceCodeLocationInfo: true });
851
+ let magicString = new MagicString.default(html);
852
+ /** 遍历树处理JS */
853
+ const eachJSNode = (node) => {
854
+ if (node.tagName == "head") {
855
+ tags.forEach((tag) => {
856
+ if (tag.injectTo == "head-prepend" || !tag.injectTo) {
857
+ magicString.appendRight(node.sourceCodeLocation.startTag.endOffset, serializeTag(tag));
858
+ }
859
+ else if (tag.injectTo == "head") {
860
+ magicString.appendRight(node.sourceCodeLocation.endTag.startOffset, serializeTag(tag));
861
+ }
862
+ });
863
+ }
864
+ else if (node.tagName == "body") {
865
+ tags.forEach((tag) => {
866
+ if (tag.injectTo == "body-prepend") {
867
+ magicString.appendRight(node.sourceCodeLocation.startTag.endOffset, serializeTag(tag));
868
+ }
869
+ else if (tag.injectTo == "body") {
870
+ magicString.appendRight(node.sourceCodeLocation.endTag.startOffset, serializeTag(tag));
871
+ }
872
+ });
873
+ }
874
+ if (node.childNodes && node.childNodes.length) {
875
+ for (let child of node.childNodes) {
876
+ eachJSNode.call(node, child);
877
+ }
878
+ }
879
+ };
880
+ eachJSNode(dom);
881
+ return magicString.toString();
882
+ }
883
+ /**
884
+ * 解析请求 URL,确定对应的 HTML 文件路径:
885
+ * - /page.html → root/page.html
886
+ * - /subdir/ → root/subdir/index.html
887
+ * - /subdir → root/subdir/index.html
888
+ * - / → root/index.html
889
+ * - 其他(.js/.css) → null(跳过,交给 serve-static)
890
+ */
891
+ function resolveHtmlPath(url, root) {
892
+ const ext = path.extname(url);
893
+ if (ext === '.html') {
894
+ return path.join(root, url);
895
+ }
896
+ if (!ext || url.endsWith('/')) {
897
+ return path.join(root, url, 'index.html');
898
+ }
899
+ return null;
900
+ }
901
+ async function fileExists(filePath) {
902
+ try {
903
+ await fs.promises.access(filePath, fs.constants.R_OK);
904
+ return true;
905
+ }
906
+ catch {
907
+ return false;
908
+ }
909
+ }
910
+ /**
911
+ * HTML 中间件:
912
+ * 1. 判断请求是否为 HTML(.html 或目录/index.html)
913
+ * 2. 依次调用 loadHtml 钩子;首个返回非空字符串者直接作为 HTML(跳过磁盘读取)
914
+ * 3. 无钩子命中时回退到读取磁盘文件
915
+ * 4. 如果提供了 transformHtml 自定义转换规则,执行它(支持异步)
916
+ */
917
+ function htmlMiddleware(root, htmlTransformers, loadHtmlHooks) {
918
+ return async function (req, res, next) {
919
+ const url = req.url;
920
+ const filePath = resolveHtmlPath(url, root);
921
+ // 非 HTML 请求,交给后续中间件处理
922
+ if (!filePath) {
923
+ return next();
924
+ }
925
+ try {
926
+ // Step 1: 调用 loadHtml 钩子,首个命中即采用
927
+ let html;
928
+ if (loadHtmlHooks) {
929
+ for (const hook of loadHtmlHooks) {
930
+ const result = await hook(url);
931
+ if (result != null) {
932
+ html = result;
933
+ break;
934
+ }
935
+ }
936
+ }
937
+ // Step 2: 无钩子命中,回退到磁盘文件
938
+ if (html == null) {
939
+ // 文件不存在,交给 serve-static 返回 404
940
+ if (!(await fileExists(filePath))) {
941
+ return next();
942
+ }
943
+ html = await fs.promises.readFile(filePath, 'utf-8');
944
+ }
945
+ // Step 3: 执行 transformHtml 转换规则(支持异步)
946
+ if (htmlTransformers) {
947
+ let ctx = { filename: url };
948
+ for (let hook of htmlTransformers) {
949
+ let r = await hook(html, ctx);
950
+ if (r) {
951
+ if (typeof r === "string") {
952
+ html = r;
953
+ }
954
+ else if (Array.isArray(r) || r instanceof Array) {
955
+ html = insertHtmlNode(html, r);
956
+ }
957
+ else {
958
+ html = insertHtmlNode(r.html, r.tags);
959
+ }
960
+ }
961
+ }
962
+ }
963
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
964
+ res.end(html);
965
+ }
966
+ catch (err) {
967
+ console.error('HTML transform error:', err);
968
+ res.statusCode = 500;
969
+ res.end('HTML transform error');
970
+ }
971
+ };
972
+ }
973
+
974
+ function proxyMiddleware(proxyTable) {
975
+ const proxies = {};
976
+ Object.keys(proxyTable).forEach(function (context) {
977
+ let opts = proxyTable[context];
978
+ if (!opts) {
979
+ return;
980
+ }
981
+ if (typeof opts === 'string') {
982
+ opts = { target: opts, changeOrigin: true };
983
+ }
984
+ let { rewrite, configure, bypass, ...proxyOptions } = opts;
985
+ const proxy = HttpProxy.createProxyServer(proxyOptions);
986
+ if (opts.configure) {
987
+ opts.configure(proxy, opts);
988
+ }
989
+ proxy.on('error', (err, req, res) => {
990
+ // When it is ws proxy, res is net.Socket
991
+ // originalRes can be falsy if the proxy itself errored
992
+ if (!res) {
993
+ console.error(`${pc.red(`http proxy error: ${err.message}`)}\n${err.stack}`);
994
+ }
995
+ else if ('req' in res) {
996
+ console.error(`${pc.red(`http proxy error at ${res.req.url}:`)}\n${err.stack}`);
997
+ if (!res.headersSent && !res.writableEnded) {
998
+ res
999
+ .writeHead(500, {
1000
+ 'Content-Type': 'text/plain',
1001
+ })
1002
+ .end();
1003
+ }
1004
+ }
1005
+ else {
1006
+ console.error(`${pc.red(`ws proxy error:`)}\n${err.stack}`);
1007
+ res.end();
1008
+ }
1009
+ });
1010
+ proxies[context] = [proxy, opts];
1011
+ });
1012
+ return function (req, res, next) {
1013
+ const url = req.url;
1014
+ for (const context in proxies) {
1015
+ if (doesProxyContextMatchUrl(context, url)) {
1016
+ const [proxy, opts] = proxies[context];
1017
+ const { rewrite, configure, bypass, ...proxyOptions } = opts;
1018
+ const options = {};
1019
+ if (bypass) {
1020
+ const bypassResult = bypass(req, res, proxyOptions);
1021
+ if (typeof bypassResult === 'string') {
1022
+ req.url = bypassResult;
1023
+ console.debug(`bypass: ${req.url} -> ${bypassResult}`);
1024
+ return next();
1025
+ }
1026
+ else if (typeof bypassResult === 'object') {
1027
+ Object.assign(options, bypassResult);
1028
+ console.debug(`bypass: ${req.url} use modified options: %O ` + JSON.stringify(options));
1029
+ return next();
1030
+ }
1031
+ else if (bypassResult === false) {
1032
+ console.debug(`bypass: ${req.url} -> 404`);
1033
+ return res.end(404);
1034
+ }
1035
+ }
1036
+ console.debug(`${req.url} -> ${opts.target || opts.forward}`);
1037
+ if (rewrite) {
1038
+ req.url = rewrite(req.url);
1039
+ }
1040
+ proxy.web(req, res, options);
1041
+ return;
1042
+ }
1043
+ }
1044
+ next();
1045
+ };
1046
+ }
1047
+ function doesProxyContextMatchUrl(context, url) {
1048
+ return ((context.charAt(0) === '^' && new RegExp(context).test(url)) ||
1049
+ url.startsWith(context));
1050
+ }
1051
+
1052
+ function queryMiddleware() {
1053
+ return function (req, res, next) {
1054
+ let url = req.originalUrl;
1055
+ if (url) {
1056
+ var index = url.indexOf("?");
1057
+ if (index >= 0) {
1058
+ req.url = url.substring(0, index);
1059
+ req.query = querystring.parse(url.substring(index + 1));
1060
+ }
1061
+ }
1062
+ next();
1063
+ };
1064
+ }
1065
+
1066
+ // 禁缓存响应头:测试服务器每次都应返回最新构建产物。
1067
+ // no-store 只能阻止新的缓存写入,浏览器已存在的缓存条目不受影响,
1068
+ // 需要彻底干净的场合配合浏览器的空缓存目录(见 run.ts 的 Chrome preset)。
1069
+ const DEFAULT_HEADERS = {
1070
+ "Cache-Control": "no-store",
1071
+ "Pragma": "no-cache",
1072
+ "Expires": "0"
1073
+ };
1074
+ async function serve(options = {}) {
1075
+ const resolvedOptions = {
1076
+ root: process.cwd(),
1077
+ port: 80,
1078
+ host: "localhost",
1079
+ https: false,
1080
+ contextPath: "/",
1081
+ ...options
1082
+ };
1083
+ const { root, port, host, https, cors, contextPath, proxy, plugins, headers } = resolvedOptions;
1084
+ const htmlTransformers = resolvedOptions.htmlTransformers
1085
+ || (plugins && plugins.map(p => p.transformHtml).filter(Boolean));
1086
+ const loadHtmlHooks = plugins && plugins.map(p => p.loadHtml).filter(Boolean);
1087
+ let app = connect();
1088
+ // configureServer:内置中间件 use 之前运行(支持异步)。
1089
+ // 返回的函数被收集,等内置中间件 use 完之后再依次运行。
1090
+ const configureServerHooks = plugins && plugins.map(p => p.configureServer).filter(Boolean);
1091
+ const ctx = { middlewares: app };
1092
+ const postHooks = [];
1093
+ if (configureServerHooks) {
1094
+ for (const hook of configureServerHooks) {
1095
+ const result = await hook(ctx);
1096
+ if (typeof result === "function") {
1097
+ postHooks.push(result);
1098
+ }
1099
+ }
1100
+ }
1101
+ // 响应头:默认禁缓存,headers 选项可覆盖/追加同名头以外的自定义头
1102
+ const mergedHeaders = Object.assign({}, DEFAULT_HEADERS, headers);
1103
+ app.use(function (_req, res, next) {
1104
+ for (const key in mergedHeaders) {
1105
+ res.setHeader(key, mergedHeaders[key]);
1106
+ }
1107
+ next();
1108
+ });
1109
+ if (proxy) {
1110
+ app.use(proxyMiddleware(proxy));
1111
+ }
1112
+ app.use(queryMiddleware());
1113
+ app.use(callMiddleware());
1114
+ app.use(contextPath, htmlMiddleware(root, htmlTransformers, loadHtmlHooks));
1115
+ app.use(contextPath, serveStatic(root, { index: ['index.html'] }));
1116
+ // configureServer 返回的回调:内置中间件 use 之后运行(支持异步)
1117
+ for (const hook of postHooks) {
1118
+ await hook(ctx);
1119
+ }
1120
+ return new Promise((resolve, reject) => {
1121
+ let httpServer = app.listen(port, host, function (error) {
1122
+ if (error) {
1123
+ reject(error);
1124
+ return;
1125
+ }
1126
+ let fullUrl = (https ? "https" : "http") + "://" + (host || "localhost") + ":" + port + contextPath;
1127
+ console.info(`Server is running at ${pc.cyan(fullUrl)}`);
1128
+ resolve(httpServer);
1129
+ });
1130
+ });
1131
+ }
1132
+
1133
+ exports.EmulateIE = EmulateIE;
1134
+ exports.Mocha = Mocha;
1135
+ exports.QUnit = QUnit;
1136
+ exports.judge = judge;
1137
+ exports.report = report;
1138
+ exports.run = run;
1139
+ exports.runQueue = runQueue;
1140
+ exports.scan = scan;
1141
+ exports.serve = serve;