ui5-test-runner 3.1.1 → 3.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui5-test-runner",
3
- "version": "3.1.1",
3
+ "version": "3.3.0",
4
4
  "description": "Standalone test runner for UI5",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -16,13 +16,16 @@
16
16
  },
17
17
  "scripts": {
18
18
  "lint": "standard --fix",
19
- "test": "npm run test:unit && npm run test:integration:jsdom && npm run test:integration:puppeteer && npm run test:integration:selenium-webdriver-chrome",
19
+ "test": "npm run test:unit && npm run test:integration:jsdom && npm run test:integration:puppeteer && npm run test:integration:selenium-webdriver-chrome && npm run test:integration:playwright",
20
+ "test:coverall": "rimraf .nyc_output && jest --coverageDirectory .nyc_output --coverageReporters json && nyc --silent --no-clean npm run test:integration:jsdom && nyc --silent --no-clean npm run test:integration:puppeteer && nyc --silent --no-clean npm run test:integration:selenium-webdriver-chrome && nyc --silent --no-clean npm run test:integration:playwright && nyc merge .nyc_output .nyc_output/final/coverage.json && nyc report --temp-dir .nyc_output/final/ --report-dir coverage --branches 80 --functions 80 --lines 80 --statements 80",
20
21
  "test:unit": "jest",
21
22
  "test:unit:debug": "jest --runInBand",
22
23
  "test:integration:puppeteer": "node . --capabilities --browser $/puppeteer.js",
23
24
  "test:integration:selenium-webdriver-chrome": "node . --capabilities --browser $/selenium-webdriver.js -- --browser chrome",
24
25
  "test:integration:jsdom": "node . --capabilities --browser $/jsdom.js",
26
+ "test:integration:playwright": "node . --capabilities --browser $/playwright.js",
25
27
  "test:report": "node ./src/defaults/report.js ./test/report && reserve --config ./test/report/reserve.json",
28
+ "test:text-report": "node ./src/defaults/text-report.js ./test/report",
26
29
  "build:doc": "node build/doc"
27
30
  },
28
31
  "repository": {
@@ -45,20 +48,20 @@
45
48
  },
46
49
  "homepage": "https://github.com/ArnaudBuchholz/ui5-test-runner#readme",
47
50
  "dependencies": {
48
- "commander": "^10.0.1",
51
+ "commander": "^11.0.0",
49
52
  "mime": "^3.0.0",
50
53
  "punybind": "^1.2.1",
51
54
  "punyexpr": "^1.0.4",
52
- "reserve": "^1.15.2"
55
+ "reserve": "^1.15.3"
53
56
  },
54
57
  "devDependencies": {
55
- "jest": "^29.5.0",
56
- "nock": "^13.3.1",
58
+ "jest": "^29.7.0",
59
+ "nock": "^13.3.3",
57
60
  "nyc": "^15.1.0",
58
61
  "standard": "^17.1.0"
59
62
  },
60
63
  "optionalDependencies": {
61
- "fsevents": "^2.3.2"
64
+ "fsevents": "^2.3.3"
62
65
  },
63
66
  "standard": {
64
67
  "env": [
package/src/coverage.js CHANGED
@@ -1,18 +1,28 @@
1
1
  'use strict'
2
2
 
3
- const { join } = require('path')
3
+ const { join, dirname } = require('path')
4
4
  const { fork } = require('child_process')
5
- const { cleanDir, createDir, filename } = require('./tools')
6
- const { readdir, readFile, stat, writeFile } = require('fs').promises
5
+ const { cleanDir, createDir, filename, download } = require('./tools')
6
+ const { readdir, readFile, stat, writeFile, access, constants } = require('fs').promises
7
7
  const { Readable } = require('stream')
8
8
  const { getOutput } = require('./output')
9
9
  const { resolvePackage } = require('./npm')
10
+ const { promisify } = require('util')
10
11
 
11
12
  const $nycSettingsPath = Symbol('nycSettingsPath')
12
13
  const $coverageFileIndex = Symbol('coverageFileIndex')
14
+ const $coverageRemote = Symbol('coverageRemote')
13
15
 
16
+ let nycInstallationPath
14
17
  let nycScript
15
18
 
19
+ async function setupNyc (job) {
20
+ if (!nycInstallationPath) {
21
+ nycInstallationPath = resolvePackage(job, 'nyc')
22
+ }
23
+ nycScript = join(await nycInstallationPath, 'bin/nyc.js')
24
+ }
25
+
16
26
  async function nyc (job, ...args) {
17
27
  const output = getOutput(job)
18
28
  output.nyc(...args)
@@ -43,10 +53,7 @@ const customFileSystem = {
43
53
  }
44
54
 
45
55
  async function instrument (job) {
46
- if (!nycScript) {
47
- const nyc = await resolvePackage(job, 'nyc')
48
- nycScript = join(nyc, 'bin/nyc.js')
49
- }
56
+ await setupNyc(job)
50
57
  job[$nycSettingsPath] = join(job.coverageTempDir, 'settings/nyc.json')
51
58
  await cleanDir(job.coverageTempDir)
52
59
  await createDir(join(job.coverageTempDir, 'settings'))
@@ -71,6 +78,7 @@ async function instrument (job) {
71
78
  })
72
79
  if (!useLocal) {
73
80
  getOutput(job).instrumentationSkipped()
81
+ job[$coverageRemote] = true
74
82
  return
75
83
  }
76
84
  }
@@ -81,9 +89,28 @@ async function instrument (job) {
81
89
  async function generateCoverageReport (job) {
82
90
  job.status = 'Generating coverage report'
83
91
  await cleanDir(job.coverageReportDir)
84
- await nyc(job, 'merge', job.coverageTempDir, join(job.coverageTempDir, 'coverage.json'))
92
+ const coverageMergedDir = join(job.coverageTempDir, 'merged')
93
+ await createDir(coverageMergedDir)
94
+ const coverageFilename = join(coverageMergedDir, 'coverage.json')
95
+ await nyc(job, 'merge', job.coverageTempDir, coverageFilename)
96
+ if (job[$coverageRemote] && !job.coverageProxy) {
97
+ job.status = 'Collecting remote source files'
98
+ // Assuming all files are coming from the same server
99
+ const { origin } = new URL(job.testPageUrls[0])
100
+ const sourcesBasePath = join(job.coverageTempDir, 'sources')
101
+ const coverageData = require(coverageFilename)
102
+ const filenames = Object.keys(coverageData)
103
+ for (const filename of filenames) {
104
+ const fileData = coverageData[filename]
105
+ const { path } = fileData
106
+ const filePath = join(sourcesBasePath, path)
107
+ fileData.path = filePath
108
+ await download(origin + path, filePath)
109
+ }
110
+ await writeFile(coverageFilename, JSON.stringify(coverageData))
111
+ }
85
112
  const reporters = job.coverageReporters.map(reporter => `--reporter=${reporter}`)
86
- await nyc(job, 'report', ...reporters, '--temp-dir', job.coverageTempDir, '--report-dir', job.coverageReportDir, '--nycrc-path', job[$nycSettingsPath])
113
+ await nyc(job, 'report', ...reporters, '--temp-dir', coverageMergedDir, '--report-dir', job.coverageReportDir, '--nycrc-path', job[$nycSettingsPath])
87
114
  }
88
115
 
89
116
  module.exports = {
@@ -97,12 +124,59 @@ module.exports = {
97
124
  await writeFile(coverageFileName, JSON.stringify(coverageData))
98
125
  },
99
126
  generateCoverageReport: job => job.coverage && generateCoverageReport(job),
100
- mappings: job => job.coverage
101
- ? [{
102
- match: /^\/(.*\.js)$/,
103
- file: join(job.coverageTempDir, 'instrumented', '$1'),
104
- 'ignore-if-not-found': true,
127
+ mappings: async job => {
128
+ if (!job.coverage) {
129
+ return []
130
+ }
131
+ const instrumentedBasePath = join(job.coverageTempDir, 'instrumented')
132
+ const instrumentedMapping = {
133
+ match: /^\/(.*\.js)$/,
134
+ file: join(instrumentedBasePath, '$1'),
135
+ 'ignore-if-not-found': true
136
+ }
137
+ if (job.mode === 'legacy') {
138
+ return [{
139
+ ...instrumentedMapping,
105
140
  'custom-file-system': job.debugCoverageNoCustomFs ? undefined : customFileSystem
106
141
  }]
107
- : []
142
+ }
143
+ if (job.mode === 'url' && job.coverageProxy) {
144
+ await setupNyc(job)
145
+ const { origin } = new URL(job.url[0])
146
+ const sourcesBasePath = join(job.coverageTempDir, 'sources')
147
+ const { createInstrumenter } = require(join(await nycInstallationPath, 'node_modules/istanbul-lib-instrument'))
148
+ const instrumenter = createInstrumenter({
149
+ produceSourceMap: true,
150
+ coverageGlobalScope: 'window.top',
151
+ coverageGlobalScopeFunc: false
152
+ })
153
+ const instrument = promisify(instrumenter.instrument.bind(instrumenter))
154
+ return [{
155
+ match: /(.*\.js)(\?.*)?$/,
156
+ custom: async (request, response, url) => {
157
+ if (!url.match(job.coverageProxyInclude) || url.match(job.coverageProxyExclude)) {
158
+ return // Ignore
159
+ }
160
+ const sourcePath = join(sourcesBasePath, url)
161
+ try {
162
+ await access(sourcePath, constants.R_OK)
163
+ } catch (e) {
164
+ console.log('download', url)
165
+ await download(origin + url, sourcePath)
166
+ }
167
+ const source = (await readFile(sourcePath)).toString()
168
+ const instrumentedSource = await instrument(source, sourcePath)
169
+ const instrumentedSourcePath = join(instrumentedBasePath, url)
170
+ await createDir(dirname(instrumentedSourcePath))
171
+ await writeFile(instrumentedSourcePath, instrumentedSource)
172
+ }
173
+ },
174
+ instrumentedMapping,
175
+ {
176
+ match: /(.*)$/,
177
+ url: `${origin}$1`
178
+ }]
179
+ }
180
+ return []
181
+ }
108
182
  }
@@ -40,7 +40,7 @@ describe('src/coverage', () => {
40
40
  })
41
41
 
42
42
  it('does not create a mapping', async () => {
43
- const coverageMappings = mappings(job)
43
+ const coverageMappings = await mappings(job)
44
44
  expect(coverageMappings.length).toStrictEqual(0)
45
45
  })
46
46
  })
@@ -73,7 +73,7 @@ describe('src/coverage', () => {
73
73
  })
74
74
 
75
75
  it('creates a mapping', async () => {
76
- const coverageMappings = mappings(job)
76
+ const coverageMappings = await mappings(job)
77
77
  expect(coverageMappings.length).toStrictEqual(1)
78
78
  })
79
79
 
@@ -0,0 +1,145 @@
1
+ 'use strict'
2
+
3
+ const { InvalidArgumentError } = require('commander')
4
+ const { join } = require('path')
5
+ const { exec } = require('child_process')
6
+
7
+ let browser
8
+ let context
9
+ let page
10
+
11
+ function browserSelector (value, defaultValue) {
12
+ if (value === undefined) {
13
+ return 'chromium'
14
+ }
15
+ if (!['chromium', 'firefox', 'webkit'].includes(value)) {
16
+ throw new InvalidArgumentError('Browser name')
17
+ }
18
+ return value
19
+ }
20
+
21
+ require('./browser')({
22
+ metadata: {
23
+ name: 'playwright',
24
+ options: [
25
+ ['-b, --browser <name>', 'Browser driver', browserSelector, 'chromium'],
26
+ ['--visible [flag]', 'Show the browser', false],
27
+ ['-w, --viewport-width <width>', 'Viewport width', 1280],
28
+ ['-h, --viewport-height <height>', 'Viewport height', 720],
29
+ ['-l, --language <lang>', 'Language', 'en-US'],
30
+ ['-u, --unsecure', 'Disable security features', false],
31
+ ['-v, --video', 'Record video', false],
32
+ ['-n, --har', 'Record network activity with har file', false]
33
+ ]
34
+ },
35
+
36
+ async capabilities ({ settings, options }) {
37
+ const capabilities = {
38
+ modules: ['playwright'],
39
+ screenshot: '.png',
40
+ scripts: true,
41
+ traces: ['console', 'network']
42
+ }
43
+ if (!settings.modules) {
44
+ return {
45
+ ...capabilities,
46
+ 'probe-with-modules': true
47
+ }
48
+ }
49
+ return await new Promise((resolve, reject) => {
50
+ exec('npx playwright install', (err, stdout, stderr) => {
51
+ console.log(stdout)
52
+ console.error(stderr)
53
+ if (err) {
54
+ reject(new Error('Unable to finalize playwright installation'))
55
+ } else {
56
+ resolve(capabilities)
57
+ }
58
+ })
59
+ })
60
+ },
61
+
62
+ async screenshot ({ filename }) {
63
+ if (page) {
64
+ await page.screenshot({
65
+ path: filename,
66
+ fullPage: true
67
+ })
68
+ return true
69
+ }
70
+ },
71
+
72
+ async beforeExit () {
73
+ if (page) {
74
+ await page.close()
75
+ }
76
+ if (context) {
77
+ await context.close()
78
+ }
79
+ if (browser) {
80
+ await browser.close()
81
+ }
82
+ },
83
+
84
+ async run ({
85
+ settings: { url, scripts, modules, dir },
86
+ options,
87
+ consoleWriter,
88
+ networkWriter
89
+ }) {
90
+ const browsers = require(modules.playwright)
91
+ browser = await browsers[options.browser].launch()
92
+
93
+ let recordVideo
94
+ if (options.video) {
95
+ recordVideo = {
96
+ dir
97
+ }
98
+ }
99
+
100
+ let recordHar
101
+ if (options.har) {
102
+ recordHar = {
103
+ path: join(dir, 'network.har')
104
+ }
105
+ }
106
+
107
+ context = await browser.newContext({
108
+ viewport: {
109
+ width: options.viewportWidth,
110
+ height: options.viewportHeight
111
+ },
112
+ locale: options.language,
113
+ bypassCSP: options.unsecure,
114
+ ignoreHTTPSErrors: options.unsecure,
115
+ recordVideo,
116
+ recordHar
117
+ })
118
+
119
+ context.setDefaultNavigationTimeout(0)
120
+
121
+ if (scripts && scripts.length) {
122
+ for (const content of scripts) {
123
+ await context.addInitScript({ content })
124
+ }
125
+ }
126
+
127
+ page = await context.newPage()
128
+
129
+ page
130
+ .on('console', message => consoleWriter.append({
131
+ type: message.type(),
132
+ text: message.text()
133
+ }))
134
+ .on('response', response => {
135
+ const request = response.request()
136
+ networkWriter.append({
137
+ method: request.method(),
138
+ url: response.url(),
139
+ status: response.status()
140
+ })
141
+ })
142
+
143
+ await page.goto(url)
144
+ }
145
+ })
@@ -93,7 +93,7 @@ require('./browser')({
93
93
  })
94
94
 
95
95
  if (scripts && scripts.length) {
96
- for await (const script of scripts) {
96
+ for (const script of scripts) {
97
97
  await page.evaluateOnNewDocument(script)
98
98
  }
99
99
  }
@@ -15,6 +15,7 @@
15
15
  <table style="visibility: {{ testPageUrls.length > 0 ? 'visible' : 'hidden' }};">
16
16
  <tr>
17
17
  <th>&nbsp;</th>
18
+ <th class="count">Type</th>
18
19
  <th class="status" style="width: 10rem;"><div><span>Status</span></div></th>
19
20
  <th class="count">Tests</th>
20
21
  <th class="count">Passed</th>
@@ -26,6 +27,11 @@
26
27
  <span {{if}}="qunitPages === undefined || !qunitPages[url]">{{ url }}</span>
27
28
  <a {{else}} href="#{{ qunitPages[url].id }}">{{ url }}</a>
28
29
  </td>
30
+ <td>
31
+ <span {{if}}="!qunitPages[url]">-</span>
32
+ <span {{elseif}}="qunitPages[url].isOpa" title="OPA test">&#129404;</span>
33
+ <span {{else}} title="Unit test">&#129514;</span>
34
+ </td>
29
35
  <td>
30
36
  <span {{if}}="!qunitPages[url]">-</span>
31
37
  <span {{elseif}}="qunitPages[url].failed">&#10060;</span>
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+
3
+ const { join, isAbsolute } = require('path')
4
+ const [, , reportDir] = process.argv
5
+ const { pad } = require('../tools')
6
+
7
+ const p = pad(process.stdout.columns || 80)
8
+ const log = console.log.bind(console)
9
+
10
+ function collectErrors (page) {
11
+ const errors = []
12
+ page.modules.forEach(module => {
13
+ module.tests.forEach(test => {
14
+ if (test.report.failed) {
15
+ errors.push({ module: module.name, ...test })
16
+ }
17
+ })
18
+ })
19
+ return errors
20
+ }
21
+
22
+ async function main () {
23
+ let jobPath
24
+ if (isAbsolute(reportDir)) {
25
+ jobPath = join(reportDir, 'job.js')
26
+ } else {
27
+ jobPath = join(process.cwd(), reportDir, 'job.js')
28
+ }
29
+ const job = require(jobPath)
30
+ const failedUrls = []
31
+ log(p`┌─${pad.x('─')}───────────────────┐`)
32
+ function render (url) {
33
+ const page = job.qunitPages && job.qunitPages[url]
34
+ if (!page || !page.report) {
35
+ log(p`│${pad.lt(url)} 🧨 │`)
36
+ failedUrls.push(url)
37
+ } else {
38
+ const type = page.isOpa ? '🥼' : '🧪'
39
+ const status = page.report.failed > 0 ? '🐞' : ' '
40
+ if (page.report.failed > 0) {
41
+ failedUrls.push(url)
42
+ }
43
+ log(p`│${pad.lt(url)} ${type} ${status} ${page.passed.toString().padStart(3, ' ')}/${page.count.toString().padEnd(3, ' ')}│`)
44
+ }
45
+ }
46
+ job.testPageUrls.forEach(render)
47
+ Object.keys(job.qunitPages || []).forEach(url => {
48
+ if (!job.testPageUrls.includes(url)) {
49
+ render(url)
50
+ }
51
+ })
52
+ log(p`└─${pad.x('─')}────────────────────┘`)
53
+ failedUrls.forEach(url => {
54
+ log()
55
+ log(p`[${pad.lt(url)}]`)
56
+ const page = job.qunitPages && job.qunitPages[url]
57
+ if (!page) {
58
+ log(p`Unable to run the page (check the execution log)`)
59
+ } else {
60
+ const pageIndex = job.testPageUrls.indexOf(url)
61
+ const pageHash = job.testPageHashes[pageIndex]
62
+ if (pageHash) {
63
+ log(p`Page execution folder name : ${pageHash}`)
64
+ }
65
+ let errors = collectErrors(page)
66
+ const { length } = errors
67
+ if (page.isOpa) {
68
+ // Focus on the first error only
69
+ errors = errors.slice(0, 1)
70
+ }
71
+ errors.forEach((test, index) => {
72
+ if (index > 0) {
73
+ log()
74
+ }
75
+ log(`${test.module} ▶ ${test.name}`)
76
+ test.logs.filter(({ result }) => !result).forEach(({ message }) => log(message))
77
+ })
78
+ if (page.isOpa && length > 1) {
79
+ log()
80
+ log(p`(${length} errors occurred but it is recommended to focus on the first OPA error)`)
81
+ }
82
+ }
83
+ })
84
+ }
85
+
86
+ main()
87
+ .catch(reason => {
88
+ console.error(reason)
89
+ return -1
90
+ })
91
+ .then((code = 0) => {
92
+ process.exit(code)
93
+ })
@@ -0,0 +1,27 @@
1
+ (function () {
2
+ 'use strict'
3
+
4
+ // inspired from ui5/resources/sap/ui/qunit/qunit-coverage-istanbul-dbg.js
5
+
6
+ function appendUrlParameter (url) {
7
+ const urlObject = new URL(url, document.baseURI)
8
+ urlObject.searchParams.set('instrument', 'true')
9
+ return urlObject.toString()
10
+ }
11
+
12
+ const nativeSetAttribute = HTMLScriptElement.prototype.setAttribute
13
+ HTMLScriptElement.prototype.setAttribute = function (name, value) {
14
+ if (name === 'data-sap-ui-module') {
15
+ this.src = appendUrlParameter(this.src)
16
+ }
17
+ nativeSetAttribute.apply(this, arguments)
18
+ }
19
+
20
+ const nativeXhrOpen = XMLHttpRequest.prototype.open
21
+ XMLHttpRequest.prototype.open = function (method, url) {
22
+ if (window.sap && window.sap.ui && window.sap.ui.loader && url && url.endsWith('.js')) {
23
+ arguments[1] = appendUrlParameter(url)
24
+ }
25
+ nativeXhrOpen.apply(this, arguments)
26
+ }
27
+ }())
package/src/job-mode.js CHANGED
@@ -34,9 +34,22 @@ function buildAndCheckMode (job) {
34
34
  return 'capabilities'
35
35
  }
36
36
  if (job.url && job.url.length) {
37
- check(job, undefined, ['testsuite'])
37
+ check(job, undefined, [
38
+ 'ui5',
39
+ 'libs',
40
+ 'mappings',
41
+ 'cache',
42
+ 'webapp',
43
+ 'watch',
44
+ 'testsuite'
45
+ ])
38
46
  return 'url'
39
47
  }
48
+ check(job, undefined, [
49
+ 'coverageProxy',
50
+ 'coverageProxyInclude',
51
+ 'coverageProxyExclude'
52
+ ])
40
53
  return 'legacy'
41
54
  }
42
55
 
package/src/job.js CHANGED
@@ -7,7 +7,7 @@ const { name, description, version } = require(join(__dirname, '../package.json'
7
7
  const { getOutput } = require('./output')
8
8
  const { $valueSources } = require('./symbols')
9
9
  const { buildAndCheckMode } = require('./job-mode')
10
- const { boolean, integer, timeout, url, arrayOf } = require('./options')
10
+ const { boolean, integer, timeout, url, arrayOf, regex } = require('./options')
11
11
 
12
12
  const $status = Symbol('status')
13
13
 
@@ -76,7 +76,8 @@ function getCommand (cwd) {
76
76
  const command = new Command()
77
77
  command.exitOverride()
78
78
 
79
- const DEBUG_OPTION = '(For debugging purpose)'
79
+ const DEBUG_OPTION = '(🐞 for debugging purpose)'
80
+ const EXPERIMENTAL_OPTION = '[⚠️ experimental]'
80
81
 
81
82
  command
82
83
  .name(name)
@@ -115,6 +116,14 @@ function getCommand (cwd) {
115
116
  .option('-rg, --report-generator <path...>', '[💻🔗] Report generator paths (relative to cwd or use $/ for provided ones)', ['$/report.js'])
116
117
  .option('-pp, --progress-page <path>', '[💻🔗] progress page path (relative to cwd or use $/ for provided ones)', '$/report/default.html')
117
118
 
119
+ .option('--coverage [flag]', '[💻🔗] Enable or disable code coverage', boolean)
120
+ .option('--no-coverage', '[💻🔗] Disable code coverage')
121
+ .option('-cs, --coverage-settings <path>', '[💻🔗] Path to a custom nyc.json file providing settings for instrumentation (relative to cwd or use $/ for provided ones)', '$/nyc.json')
122
+ .option('-ct, --coverage-temp-dir <path>', '[💻🔗] Directory to output raw coverage information to (relative to cwd)', '.nyc_output')
123
+ .option('-cr, --coverage-report-dir <path>', '[💻🔗] Directory to store the coverage report files (relative to cwd)', 'coverage')
124
+ .option('-cr, --coverage-reporters <reporter...>', '[💻🔗] List of nyc reporters to use', ['lcov', 'cobertura'])
125
+ .option('-s, --serve-only [flag]', '[💻🔗] Serve only', boolean, false)
126
+
118
127
  // Specific to legacy
119
128
  .option('--ui5 <url>', '[💻] UI5 url', url, 'https://ui5.sap.com')
120
129
  .option('--libs <lib...>', '[💻] Library mapping (<relative>=<path> or <path>)', arrayOf(lib))
@@ -122,14 +131,12 @@ function getCommand (cwd) {
122
131
  .option('--cache <path>', '[💻] Cache UI5 resources locally in the given folder (empty to disable)')
123
132
  .option('--webapp <path>', '[💻] Base folder of the web application (relative to cwd)', 'webapp')
124
133
  .option('--testsuite <path>', '[💻] Path of the testsuite file (relative to webapp)', 'test/testsuite.qunit.html')
125
- .option('-s, --serve-only [flag]', '[💻] Serve only', boolean, false)
126
134
  .option('-w, --watch [flag]', '[💻] Monitor the webapp folder and re-execute tests on change', boolean, false)
127
- .option('--coverage [flag]', '[💻] Enable or disable code coverage', boolean)
128
- .option('--no-coverage', '[💻] Disable code coverage')
129
- .option('-cs, --coverage-settings <path>', '[💻] Path to a custom nyc.json file providing settings for instrumentation (relative to cwd or use $/ for provided ones)', '$/nyc.json')
130
- .option('-ct, --coverage-temp-dir <path>', '[💻] Directory to output raw coverage information to (relative to cwd)', '.nyc_output')
131
- .option('-cr, --coverage-report-dir <path>', '[💻] Directory to store the coverage report files (relative to cwd)', 'coverage')
132
- .option('-cr, --coverage-reporters <reporter...>', '[💻] List of nyc reporters to use', ['lcov', 'cobertura'])
135
+
136
+ // Specific to coverage in url mode (experimental)
137
+ .option('-cp, --coverage-proxy [flag]', `[🔗] ${EXPERIMENTAL_OPTION} use internal proxy to instrument remote files`, boolean, false)
138
+ .option('-cpi, --coverage-proxy-include <regexp>', `[🔗] ${EXPERIMENTAL_OPTION} urls to instrument for coverage`, regex, regex('webapp/.*'))
139
+ .option('-cpe, --coverage-proxy-exclude <regexp>', `[🔗] ${EXPERIMENTAL_OPTION} urls to ignore for coverage`, regex, regex('(test-)?resources/.*'))
133
140
 
134
141
  .addOption(new Option('--debug-probe-only', DEBUG_OPTION, boolean).hideHelp())
135
142
  .addOption(new Option('--debug-keep-browser-open', DEBUG_OPTION, boolean).hideHelp())
package/src/job.spec.js CHANGED
@@ -72,9 +72,8 @@ describe('job', () => {
72
72
  expect(job.screenshot).toStrictEqual(false)
73
73
  })
74
74
 
75
- it('url disables webapp checking and coverage', () => {
75
+ it('url disables coverage by default', () => {
76
76
  const job = buildJob({
77
- webapp: 'not_a_folder',
78
77
  url: 'http://localhost:8080'
79
78
  })
80
79
  expect(job.url).toStrictEqual(['http://localhost:8080'])
@@ -83,7 +82,6 @@ describe('job', () => {
83
82
 
84
83
  it('url still allows coverage', () => {
85
84
  const job = buildJob({
86
- webapp: 'not_a_folder',
87
85
  url: 'http://localhost:8080',
88
86
  coverage: true
89
87
  })
@@ -255,6 +253,13 @@ describe('job', () => {
255
253
  })).toThrow()
256
254
  })
257
255
  })
256
+
257
+ it('url forbids the use of webapp', () => {
258
+ expect(() => buildJob({
259
+ webapp: 'not_a_folder',
260
+ url: 'http://localhost:8080'
261
+ })).toThrow(UTRError.MODE_INCOMPATIBLE_OPTION('webapp'))
262
+ })
258
263
  })
259
264
 
260
265
  describe('Using ui5-test-runner.json', () => {
package/src/options.js CHANGED
@@ -31,6 +31,14 @@ module.exports = {
31
31
  throw new InvalidArgumentError('Invalid boolean')
32
32
  },
33
33
 
34
+ regex (value) {
35
+ try {
36
+ return new RegExp(value)
37
+ } catch (e) {
38
+ throw new InvalidArgumentError('Invalid regex')
39
+ }
40
+ },
41
+
34
42
  integer,
35
43
 
36
44
  timeout (value) {
package/src/output.js CHANGED
@@ -488,44 +488,6 @@ function build (job) {
488
488
  warn(job, p80()`Some requests are not handled properly, check the unhandled.txt report for more info`)
489
489
  }),
490
490
 
491
- results: wrap(() => {
492
- const p = p80()
493
- log(job, p`┌──────────${pad.x('─')}┐`)
494
- log(job, p`│ RESULTS ${pad.x(' ')} │`)
495
- log(job, p`├─────┬─${pad.x('─')}──┤`)
496
- const messages = []
497
- function result (url) {
498
- const page = job.qunitPages && job.qunitPages[url]
499
- let message
500
- if (!page || !page.report) {
501
- message = 'Unable to run the page'
502
- } else if (page.report.failed > 1) {
503
- message = `${page.report.failed} tests failed`
504
- } else if (page.report.failed === 1) {
505
- message = '1 test failed'
506
- }
507
- if (message) {
508
- log(job, p`│ ${(messages.length + 1).toString().padStart(3, ' ')} │ ${pad.lt(url)} │`)
509
- messages.push(message)
510
- } else {
511
- log(job, p`│ OK │ ${pad.lt(url)} │`)
512
- }
513
- }
514
- job.testPageUrls.forEach(result)
515
- Object.keys(job.qunitPages || []).forEach(url => {
516
- if (!job.testPageUrls.includes(url)) {
517
- result(url)
518
- }
519
- })
520
- log(job, p`└─────┴───${pad.x('─')}┘`)
521
- messages.forEach((message, index) => {
522
- log(job, p`${(index + 1).toString().padStart(3, ' ')}: ${message}`)
523
- })
524
- if (!messages.length) {
525
- log(job, p`Success !`)
526
- }
527
- }),
528
-
529
491
  reportGeneratorFailed: wrap((generator, exitCode, buffers) => {
530
492
  const p = p80()
531
493
  log(job, p`┌──────────${pad.x('─')}┐`)
package/src/report.js CHANGED
@@ -20,6 +20,14 @@ async function save (job) {
20
20
  await serialize(job, 'job', job)
21
21
  }
22
22
 
23
+ function generateTextReport (job) {
24
+ const { promise, resolve } = allocPromise()
25
+ const childProcess = fork(join(__dirname, 'defaults/text-report.js'), [job.reportDir], { stdio: 'pipe' })
26
+ getOutput(job).monitor(childProcess, true)
27
+ childProcess.on('close', resolve)
28
+ return promise
29
+ }
30
+
23
31
  module.exports = {
24
32
  save,
25
33
 
@@ -28,9 +36,9 @@ module.exports = {
28
36
  job.end = new Date()
29
37
  job.failed = !!job.failed
30
38
  job.status = 'Generating reports'
31
- output.results()
32
39
  job.testPageHashes = job.testPageUrls.map(url => filename(url))
33
40
  await save(job)
41
+ await generateTextReport(job)
34
42
  const promises = job.reportGenerator.map(generator => {
35
43
  const { promise, resolve } = allocPromise()
36
44
  const childProcess = fork(generator, [job.reportDir], { stdio: 'pipe' })
package/src/reserve.js CHANGED
@@ -6,14 +6,14 @@ const ui5 = require('./ui5')
6
6
  const { check } = require('reserve')
7
7
  const unhandled = require('./unhandled')
8
8
 
9
- module.exports = job => check({
9
+ module.exports = async job => check({
10
10
  port: job.port,
11
11
  mappings: [
12
12
  cors,
13
13
  ...job.mappings ?? [],
14
14
  ...job.serveOnly ? [] : endpoints(job),
15
15
  ...ui5(job),
16
- ...coverage(job), {
16
+ ...await coverage(job), {
17
17
  // Project mapping
18
18
  match: /^\/(.*)/,
19
19
  file: join(job.webapp, '$1'),
package/src/tests.js CHANGED
@@ -73,11 +73,24 @@ async function runTestPage (job, url) {
73
73
  scripts = [
74
74
  'post.js',
75
75
  'qunit-intercept.js',
76
- 'qunit-hooks.js',
77
- 'opa-iframe-coverage.js'
76
+ 'qunit-hooks.js'
78
77
  ]
78
+ if (job.coverage && !job.coverageProxy) {
79
+ scripts.push(
80
+ 'opa-iframe-coverage.js',
81
+ 'ui5-coverage.js' // TODO detect if middleware exists before injecting this
82
+ )
83
+ }
84
+ }
85
+ if (job.coverageProxy) {
86
+ const { origin } = new URL(url)
87
+ const proxifiedUrl = url.replace(origin, `http://localhost:${job.port}`)
88
+ await start(job, proxifiedUrl, scripts)
89
+ job.qunitPages[url] = job.qunitPages[proxifiedUrl]
90
+ delete job.qunitPages[proxifiedUrl]
91
+ } else {
92
+ await start(job, url, scripts)
79
93
  }
80
- await start(job, url, scripts)
81
94
  } catch (error) {
82
95
  output.startFailed(url, error)
83
96
  throw error
package/src/tools.js CHANGED
@@ -1,7 +1,11 @@
1
1
  'use strict'
2
2
 
3
+ const { dirname } = require('path')
3
4
  const { mkdir, rm, stat } = require('fs').promises
4
5
  const { createHash } = require('crypto')
6
+ const { createWriteStream } = require('fs')
7
+ const http = require('http')
8
+ const https = require('https')
5
9
 
6
10
  const recursive = { recursive: true }
7
11
 
@@ -110,6 +114,38 @@ pad.x = (text) => ({ [$op]: $x, text })
110
114
  pad.lt = (text, padding = ' ') => ({ [$op]: $lt, text, padding })
111
115
  pad.w = (text) => ({ [$op]: $w, text })
112
116
 
117
+ function allocPromise () {
118
+ let resolve
119
+ let reject
120
+ const promise = new Promise((_resolve, _reject) => {
121
+ resolve = _resolve
122
+ reject = _reject
123
+ })
124
+ return { promise, resolve, reject }
125
+ }
126
+
127
+ async function download (url, filename) {
128
+ const { hostname, port, origin } = new URL(url)
129
+ const options = {
130
+ hostname,
131
+ port,
132
+ path: url.substring(origin.length),
133
+ method: 'GET'
134
+ }
135
+ const protocol = url.startsWith('https:') ? https : http
136
+ await mkdir(dirname(filename), recursive)
137
+ const output = createWriteStream(filename)
138
+ const { promise, resolve, reject } = allocPromise()
139
+ const request = protocol.request(options, response => {
140
+ response.on('error', reject)
141
+ response.on('end', resolve)
142
+ response.pipe(output)
143
+ })
144
+ request.on('error', reject)
145
+ request.end()
146
+ return promise
147
+ }
148
+
113
149
  module.exports = {
114
150
  stripUrlHash,
115
151
  filename,
@@ -117,15 +153,8 @@ module.exports = {
117
153
  createDir: dir => mkdir(dir, recursive),
118
154
  recreateDir: dir => cleanDir(dir).then(() => mkdir(dir, recursive)),
119
155
  extractPageUrl: headers => headers['x-page-url'],
120
- allocPromise () {
121
- let resolve
122
- let reject
123
- const promise = new Promise((_resolve, _reject) => {
124
- resolve = _resolve
125
- reject = _reject
126
- })
127
- return { promise, resolve, reject }
128
- },
156
+ allocPromise,
129
157
  noop () {},
130
- pad
158
+ pad,
159
+ download
131
160
  }
package/src/ui5.js CHANGED
@@ -7,6 +7,10 @@ const { capture } = require('reserve')
7
7
  const { getOutput } = require('./output')
8
8
 
9
9
  module.exports = job => {
10
+ if (job.mode === 'url') {
11
+ return []
12
+ }
13
+
10
14
  const [, hostName] = /https?:\/\/([^/]*)/.exec(job.ui5)
11
15
  const [, version] = /(\d+\.\d+\.\d+)?$/.exec(job.ui5)
12
16
  const cacheBase = join(job.cache || '', hostName.replace(':', '_'), version || '')