UNPKG

16.9 kBJavaScriptView Raw
1'use strict'
2
3const { readFileSync, writeFileSync } = require('fs')
4const { join } = require('path')
5const { memoryUsage } = require('process')
6const {
7 $browsers,
8 $probeUrlsStarted,
9 $probeUrlsCompleted,
10 $testPagesCompleted
11} = require('./symbols')
12const { filename, noop, pad } = require('./tools')
13
14const inJest = typeof jest !== 'undefined'
15const interactive = process.stdout.columns !== undefined && !inJest
16const $output = Symbol('output')
17const $outputStart = Symbol('output-start')
18
19if (!interactive) {
20 const UTF8_BOM_CODE = '\ufeff'
21 process.stdout.write(UTF8_BOM_CODE)
22}
23
24let cons
25if (inJest) {
26 cons = {
27 log: noop,
28 warn: noop,
29 error: noop
30 }
31} else {
32 cons = console
33}
34
35const formatTime = duration => {
36 duration = Math.ceil(duration / 1000)
37 const seconds = duration % 60
38 const minutes = (duration - seconds) / 60
39 return minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0')
40}
41
42const getElapsed = job => formatTime(Date.now() - job[$outputStart])
43
44const write = (...parts) => parts.forEach(part => process.stdout.write(part))
45
46function clean (job) {
47 const { lines } = job[$output]
48 if (!lines) {
49 return
50 }
51 write('\x1b[?12l')
52 write(`\x1b[${lines.toString()}F`)
53 for (let line = 0; line < lines; ++line) {
54 if (line > 1) {
55 write('\x1b[1E')
56 }
57 write(''.padEnd(process.stdout.columns, ' '))
58 }
59 if (lines > 1) {
60 write(`\x1b[${(lines - 1).toString()}F`)
61 } else {
62 write('\x1b[1G')
63 }
64}
65
66const BAR_WIDTH = 10
67
68function bar (ratio, msg) {
69 write('[')
70 if (typeof ratio === 'string') {
71 if (ratio.length > BAR_WIDTH) {
72 write(ratio.substring(0, BAR_WIDTH - 3), '...')
73 } else {
74 const padded = ratio.padStart(BAR_WIDTH - Math.floor((BAR_WIDTH - ratio.length) / 2), '-').padEnd(BAR_WIDTH, '-')
75 write(padded)
76 }
77 write('] ')
78 } else {
79 const filled = Math.floor(BAR_WIDTH * ratio)
80 write(''.padEnd(filled, '\u2588'), ''.padEnd(BAR_WIDTH - filled, '\u2591'))
81 const percent = Math.floor(100 * ratio).toString().padStart(3, ' ')
82 write('] ', percent, '%')
83 }
84 write(' ')
85 const spaceLeft = process.stdout.columns - BAR_WIDTH - 14
86 if (msg.length > spaceLeft) {
87 write('...', msg.substring(msg.length - spaceLeft - 3))
88 } else {
89 write(msg)
90 }
91 write('\n')
92}
93
94const TICKS = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
95
96function progress (job, cleanFirst = true) {
97 if (interactive) {
98 if (cleanFirst) {
99 clean(job)
100 }
101 } else {
102 if (job[$browsers]) {
103 write(`${getElapsed(job)} │ Progress\n──────┴──────────\n`)
104 } else {
105 return
106 }
107 }
108 const output = job[$output]
109 output.lines = 1
110 let progressRatio
111 if (job.debugMemory) {
112 ++output.lines
113 const { rss, heapTotal, heapUsed, external, arrayBuffers } = memoryUsage()
114 const fmt = size => `${(size / (1024 * 1024)).toFixed(2)}M`
115 write(`MEM r:${fmt(rss)}, h:${fmt(heapUsed)}/${fmt(heapTotal)}, x:${fmt(external)}, a:${fmt(arrayBuffers)}\n`)
116 }
117 if (job[$probeUrlsStarted]) {
118 const total = job.url.length + job.testPageUrls.length
119 if (job[$testPagesCompleted] !== total) {
120 progressRatio = (job[$probeUrlsCompleted] + (job[$testPagesCompleted] || 0)) / total
121 }
122 }
123 if (job[$browsers]) {
124 const runningPages = Object.keys(job[$browsers])
125 output.lines += runningPages.length
126 runningPages.forEach(pageUrl => {
127 let starting = true
128 if (job.qunitPages) {
129 const page = job.qunitPages[pageUrl]
130 if (page) {
131 const { count, passed, failed } = page
132 if (count) {
133 const progress = passed + failed
134 bar(progress / count, pageUrl)
135 starting = false
136 }
137 }
138 }
139 if (starting) {
140 bar('starting', pageUrl)
141 }
142 })
143 }
144 const status = `${TICKS[++output.lastTick % TICKS.length]} ${job.status}`
145 if (progressRatio !== undefined) {
146 bar(progressRatio, status)
147 } else {
148 write(status, '\n')
149 }
150}
151
152function output (job, ...args) {
153 writeFileSync(
154 join(job.reportDir, 'output.txt'),
155 args.map(arg => {
156 if (typeof arg === 'object') {
157 return JSON.stringify(arg, undefined, 2)
158 }
159 if (arg === undefined) {
160 return 'undefined'
161 }
162 if (arg === null) {
163 return 'null'
164 }
165 return arg.toString()
166 }).join(' ') + '\n',
167 {
168 encoding: 'utf-8',
169 flag: 'a'
170 }
171 )
172}
173
174function log (job, ...texts) {
175 cons.log(...texts)
176 output(job, ...texts)
177}
178
179function warn (job, ...texts) {
180 cons.warn(...texts)
181 output(job, ...texts)
182}
183
184function err (job, ...texts) {
185 cons.error(...texts)
186 output(job, ...texts)
187}
188
189const p80 = () => pad(process.stdout.columns || 80)
190
191function browserIssue (job, { type, url, code, dir }) {
192 const p = p80()
193 log(job, p`┌──────────${pad.x('─')}┐`)
194 log(job, p`│ BROWSER ${type.toUpperCase()} ${pad.x(' ')} │`)
195 log(job, p`├──────┬─${pad.x('─')}──┤`)
196 log(job, p`│ url │ ${pad.lt(url)} │`)
197 log(job, p`├──────┼─${pad.x('─')}──┤`)
198 const unsignedCode = new Uint32Array([code])[0]
199 log(job, p`│ code │ 0x${unsignedCode.toString(16).toUpperCase()}${pad.x(' ')} │`)
200 log(job, p`├──────┼─${pad.x('─')}──┤`)
201 log(job, p`│ dir │ ${pad.lt(dir)} │`)
202 log(job, p`├──────┴─${pad.x('─')}──┤`)
203
204 const stderr = readFileSync(join(dir, 'stderr.txt')).toString().trim()
205 if (stderr.length !== 0) {
206 log(job, p`│ Error output (${stderr.length}) ${pad.x(' ')} │`)
207 log(job, p`│ ${pad.w(stderr)} │`)
208 } else {
209 const stdout = readFileSync(join(dir, 'stdout.txt')).toString()
210 if (stdout.length !== 0) {
211 log(job, p`│ Standard output (${stderr.length}), last 10 lines... ${pad.x(' ')} │`)
212 log(job, p`│ ${pad.w('...')} │`)
213 log(job, p`│ ${pad.w(stdout.split(/\r?\n/).slice(-10).join('\n'))} │`)
214 } else {
215 log(job, p`│ No output ${pad.x(' ')} │`)
216 }
217 }
218 log(job, p`└──────────${pad.x('─')}┘`)
219}
220
221function build (job) {
222 let wrap
223 if (interactive) {
224 wrap = method => function () {
225 clean(job)
226 try {
227 method.call(this, ...arguments)
228 } finally {
229 progress(job, false)
230 }
231 }
232 } else {
233 wrap = method => method
234 }
235 job[$outputStart] = Date.now()
236
237 return {
238 lastTick: 0,
239 reportIntervalId: undefined,
240 lines: 0,
241
242 wrap: wrap(callback => callback()),
243
244 version: () => {
245 const { name, version } = require(join(__dirname, '../package.json'))
246 log(job, p80()`${name}@${version}`)
247 },
248
249 serving: url => {
250 log(job, p80()`Server running at ${pad.lt(url)}`)
251 },
252
253 debug: wrap((module, ...args) => {
254 if (job.debugVerbose && job.debugVerbose.includes(module)) {
255 console.log(`🐞${module}`, ...args)
256 output(job, `🐞${module}`, ...args)
257 }
258 }),
259
260 redirected: wrap(({ method, url, statusCode, timeSpent }) => {
261 if (url.startsWith('/_/progress')) {
262 return // avoids pollution
263 }
264 let statusText
265 if (!statusCode) {
266 statusText = 'N/A'
267 } else {
268 statusText = statusCode
269 }
270 log(job, p80()`${method.padEnd(7, ' ')} ${pad.lt(url)} ${statusText} ${timeSpent.toString().padStart(4, ' ')}ms`)
271 }),
272
273 status (status) {
274 let method
275 if (interactive) {
276 method = output
277 } else {
278 method = log
279 }
280 const text = `${getElapsed(job)}${status}`
281 method(job, '')
282 method(job, text)
283 method(job, '──────┴'.padEnd(text.length, '─'))
284 },
285
286 watching: wrap(path => {
287 log(job, p80()`Watching changes on ${pad.lt(path)}`)
288 }),
289
290 changeDetected: wrap((eventType, filename) => {
291 log(job, p80()`${eventType} ${pad.lt(filename)}`)
292 }),
293
294 reportOnJobProgress () {
295 if (interactive) {
296 this.reportIntervalId = setInterval(progress.bind(null, job), 250)
297 } else if (job.outputInterval) {
298 this.reportIntervalId = setInterval(progress.bind(null, job), job.outputInterval)
299 }
300 },
301
302 browserCapabilities: wrap(capabilities => {
303 log(job, p80()`Browser capabilities :`)
304 const { modules } = capabilities
305 if (modules.length) {
306 log(job, p80()` ├─ modules`)
307 modules.forEach((module, index) => {
308 let prefix
309 if (index === modules.length - 1) {
310 prefix = ' │ └─ '
311 } else {
312 prefix = ' │ ├─'
313 }
314 log(job, p80()`${prefix} ${pad.lt(module)}`)
315 })
316 }
317 Object.keys(capabilities)
318 .filter(key => key !== 'modules')
319 .forEach((key, index, keys) => {
320 let prefix
321 if (index === keys.length - 1) {
322 prefix = ' └─'
323 } else {
324 prefix = ' ├─'
325 }
326 log(job, p80()`${prefix} ${key}: ${JSON.stringify(capabilities[key])}`)
327 })
328 }),
329
330 resolvedPackage (name, path, version) {
331 if (!name.match(/@\d+\.\d+\.\d+$/)) {
332 name += `@${version}`
333 }
334 wrap(() => log(job, p80()`${name} in ${pad.lt(path)}`))()
335 },
336
337 packageNotLatest (name, latestVersion) {
338 wrap(() => log(job, `/!\\ latest version of ${name} is ${latestVersion}`))()
339 },
340
341 browserStart (url) {
342 const text = p80()`${getElapsed(job)} >> ${pad.lt(url)} [${filename(url)}]`
343 if (interactive) {
344 output(job, text)
345 } else {
346 wrap(() => log(job, text))()
347 }
348 },
349
350 browserStopped (url) {
351 let duration = ''
352 const page = job.qunitPages && job.qunitPages[url]
353 if (page) {
354 duration = ' (' + formatTime(page.end - page.start) + ')'
355 }
356 const text = p80()`${getElapsed(job)} << ${pad.lt(url)} ${duration} [${filename(url)}]`
357 if (interactive) {
358 output(job, text)
359 } else {
360 wrap(() => log(job, text))()
361 }
362 },
363
364 browserClosed: wrap((url, code, dir) => {
365 browserIssue(job, { type: 'unexpected closed', url, code, dir })
366 }),
367
368 browserRetry (url, retry) {
369 if (interactive) {
370 output(job, '>>', url)
371 } else {
372 wrap(() => log(job, p80()`>> RETRY ${retry} ${pad.lt(url)}`))()
373 }
374 },
375
376 browserTimeout: wrap((url, dir) => {
377 browserIssue(job, { type: 'timeout', url, code: 0, dir })
378 }),
379
380 browserFailed: wrap((url, code, dir) => {
381 browserIssue(job, { type: 'failed', url, code, dir })
382 }),
383
384 startFailed: wrap((url, error) => {
385 const p = p80()
386 log(job, p`┌──────────${pad.x('─')}┐`)
387 log(job, p`│ UNABLE TO START THE URL ${pad.x(' ')} │`)
388 log(job, p`├──────┬─${pad.x('─')}──┤`)
389 log(job, p`│ url │ ${pad.lt(url)} │`)
390 log(job, p`├──────┴─${pad.x('─')}──┤`)
391 if (error.stack) {
392 log(job, p`│ ${pad.w(error.stack)} │`)
393 } else {
394 log(job, p`│ ${pad.w(error.toString())} │`)
395 }
396 log(job, p`└──────────${pad.x('─')}┘`)
397 }),
398
399 monitor (childProcess, live = true) {
400 const defaults = {
401 stdout: { buffer: [], method: log },
402 stderr: { buffer: [], method: err }
403 };
404 ['stdout', 'stderr'].forEach(channel => {
405 childProcess[channel].on('data', chunk => {
406 const { buffer, method } = defaults[channel]
407 const text = chunk.toString()
408 if (live) {
409 if (!text.includes('\n')) {
410 buffer.push(text)
411 return
412 }
413 const cached = buffer.join('')
414 const last = text.split('\n').slice(-1)
415 buffer.length = 0
416 if (last) {
417 buffer.push(last)
418 }
419 wrap(() => method(job, cached + text.split('\n').slice(0, -1).join('\n')))()
420 } else {
421 buffer.push(text)
422 }
423 })
424 })
425 if (live) {
426 childProcess.on('close', () => {
427 ['stdout', 'stderr'].forEach(channel => {
428 const { buffer, method } = defaults[channel]
429 if (buffer.length) {
430 method(job, buffer.join(''))
431 }
432 })
433 })
434 }
435 return {
436 stdout: defaults.stdout.buffer,
437 stderr: defaults.stderr.buffer
438 }
439 },
440
441 nyc: wrap((...args) => {
442 log(job, p80()`nyc ${args.map(arg => arg.toString()).join(' ')}`)
443 }),
444
445 instrumentationSkipped: wrap(() => {
446 log(job, p80()`Skipping nyc instrumentation (--url)`)
447 }),
448
449 endpointError: wrap(({ api, url, data, error }) => {
450 const p = p80()
451 log(job, p`┌──────────${pad.x('─')}┐`)
452 log(job, p`│ UNEXPECTED ENDPOINT ERROR ${pad.x(' ')} │`)
453 log(job, p`├──────┬─${pad.x('─')}──┤`)
454 log(job, p`│ api │ ${pad.lt(api)} │`)
455 log(job, p`├──────┼─${pad.x('─')}──┤`)
456 log(job, p`│ from │ ${pad.lt(url)} │`)
457 log(job, p`├──────┴─${pad.x('─')}──┤`)
458 log(job, p`│ data (${JSON.stringify(data).length}) ${pad.x(' ')} │`)
459 log(job, p`│ ${pad.w(JSON.stringify(data, undefined, 2))} │`)
460 log(job, p`├────────${pad.x('─')}──┤`)
461 if (error.stack) {
462 log(job, p`│ ${pad.w(error.stack)} │`)
463 } else {
464 log(job, p`│ ${pad.w(error.toString())} │`)
465 }
466 log(job, p`└──────────${pad.x('─')}┘`)
467 }),
468
469 serverError: wrap(({ method, url, reason }) => {
470 const p = p80()
471 log(job, p`┌──────────${pad.x('─')}┐`)
472 log(job, p`│ UNEXPECTED SERVER ERROR ${pad.x(' ')} │`)
473 log(job, p`├──────┬─${pad.x('─')}──┤`)
474 log(job, p`│ verb │ ${pad.lt(method)} │`)
475 log(job, p`├──────┼─${pad.x('─')}──┤`)
476 log(job, p`│ url │ ${pad.lt(url)} │`)
477 log(job, p`├──────┴─${pad.x('─')}──┤`)
478 if (reason.stack) {
479 log(job, p`│ ${pad.w(reason.stack)} │`)
480 } else {
481 log(job, p`│ ${pad.w(reason.toString())} │`)
482 }
483 log(job, p`└──────────${pad.x('─')}┘`)
484 }),
485
486 globalTimeout: wrap(url => {
487 log(job, p80()`!! TIMEOUT ${pad.lt(url)}`)
488 }),
489
490 failFast: wrap(url => {
491 log(job, p80()`!! FAILFAST ${pad.lt(url)}`)
492 }),
493
494 noTestPageFound: wrap(() => {
495 err(job, p80()`No test page found (or all filtered out)`)
496 }),
497
498 failedToCacheUI5resource: wrap((path, statusCode) => {
499 err(job, p80()`Unable to cache '${pad.lt(path)}' (status ${statusCode})`)
500 }),
501
502 genericError: wrap((error, url) => {
503 const p = p80()
504 log(job, p`┌──────────${pad.x('─')}┐`)
505 log(job, p`│ UNEXPECTED ERROR ${pad.x(' ')} │`)
506 if (url) {
507 log(job, p`├──────┬─${pad.x('─')}──┤`)
508 log(job, p`│ url │ ${pad.lt(url)} │`)
509 log(job, p`├──────┴─${pad.x('─')}──┤`)
510 } else {
511 log(job, p`├────────${pad.x('─')}──┤`)
512 }
513 if (error.stack) {
514 log(job, p`│ ${pad.w(error.stack)} │`)
515 } else {
516 log(job, p`│ ${pad.w(error.toString())} │`)
517 }
518 log(job, p`└──────────${pad.x('─')}┘`)
519 }),
520
521 unhandled: wrap(() => {
522 warn(job, p80()`Some requests are not handled properly, check the unhandled.txt report for more info`)
523 }),
524
525 reportGeneratorFailed: wrap((generator, exitCode, buffers) => {
526 const p = p80()
527 log(job, p`┌──────────${pad.x('─')}┐`)
528 log(job, p`│ REPORT GENERATOR FAILED ${pad.x(' ')} │`)
529 log(job, p`├───────────┬─${pad.x('─')}──┤`)
530 log(job, p`│ generator │ ${pad.lt(generator)} │`)
531 log(job, p`├───────────┼─${pad.x('─')}──┤`)
532 log(job, p`│ exit code │ ${pad.lt(exitCode.toString())} │`)
533 log(job, p`├───────────┴─${pad.x('─')}──┤`)
534 log(job, p`│ ${pad.w(buffers.stderr.join(''))} │`)
535 log(job, p`└──────────${pad.x('─')}┘`)
536 }),
537
538 stop () {
539 if (this.reportIntervalId) {
540 clearInterval(this.reportIntervalId)
541 if (interactive) {
542 clean(job)
543 }
544 }
545 }
546 }
547}
548
549module.exports = {
550 getOutput (job) {
551 if (!job[$output]) {
552 job[$output] = build(job)
553 }
554 return job[$output]
555 }
556}