1 | 'use strict'
|
2 |
|
3 | const { readFileSync, writeFileSync } = require('fs')
|
4 | const { join } = require('path')
|
5 | const { memoryUsage } = require('process')
|
6 | const {
|
7 | $browsers,
|
8 | $probeUrlsStarted,
|
9 | $probeUrlsCompleted,
|
10 | $testPagesCompleted
|
11 | } = require('./symbols')
|
12 | const { filename, noop, pad } = require('./tools')
|
13 |
|
14 | const inJest = typeof jest !== 'undefined'
|
15 | const interactive = process.stdout.columns !== undefined && !inJest
|
16 | const $output = Symbol('output')
|
17 | const $outputStart = Symbol('output-start')
|
18 |
|
19 | if (!interactive) {
|
20 | const UTF8_BOM_CODE = '\ufeff'
|
21 | process.stdout.write(UTF8_BOM_CODE)
|
22 | }
|
23 |
|
24 | let cons
|
25 | if (inJest) {
|
26 | cons = {
|
27 | log: noop,
|
28 | warn: noop,
|
29 | error: noop
|
30 | }
|
31 | } else {
|
32 | cons = console
|
33 | }
|
34 |
|
35 | const 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 |
|
42 | const getElapsed = job => formatTime(Date.now() - job[$outputStart])
|
43 |
|
44 | const write = (...parts) => parts.forEach(part => process.stdout.write(part))
|
45 |
|
46 | function 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 |
|
66 | const BAR_WIDTH = 10
|
67 |
|
68 | function 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 |
|
94 | const TICKS = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
|
95 |
|
96 | function 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 |
|
152 | function 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 |
|
174 | function log (job, ...texts) {
|
175 | cons.log(...texts)
|
176 | output(job, ...texts)
|
177 | }
|
178 |
|
179 | function warn (job, ...texts) {
|
180 | cons.warn(...texts)
|
181 | output(job, ...texts)
|
182 | }
|
183 |
|
184 | function err (job, ...texts) {
|
185 | cons.error(...texts)
|
186 | output(job, ...texts)
|
187 | }
|
188 |
|
189 | const p80 = () => pad(process.stdout.columns || 80)
|
190 |
|
191 | function 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 |
|
221 | function 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
|
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 |
|
549 | module.exports = {
|
550 | getOutput (job) {
|
551 | if (!job[$output]) {
|
552 | job[$output] = build(job)
|
553 | }
|
554 | return job[$output]
|
555 | }
|
556 | }
|