codeceptjs 4.0.0-rc.11 → 4.0.0-rc.15
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/lib/ai.js +3 -2
- package/lib/assertions.js +18 -0
- package/lib/codecept.js +6 -6
- package/lib/command/check.js +2 -0
- package/lib/command/dryRun.js +2 -3
- package/lib/command/generate.js +2 -0
- package/lib/command/gherkin/snippets.js +5 -4
- package/lib/command/init.js +1 -0
- package/lib/command/run-multiple.js +2 -0
- package/lib/command/run-workers.js +1 -0
- package/lib/command/run.js +1 -1
- package/lib/command/workers/runTests.js +10 -10
- package/lib/container.js +63 -13
- package/lib/effects.js +17 -0
- package/lib/element/WebElement.js +128 -0
- package/lib/globals.js +22 -10
- package/lib/heal.js +4 -3
- package/lib/helper/ApiDataFactory.js +2 -1
- package/lib/helper/FileSystem.js +3 -2
- package/lib/helper/GraphQLDataFactory.js +2 -1
- package/lib/helper/Playwright.js +17 -16
- package/lib/helper/Puppeteer.js +16 -6
- package/lib/helper/WebDriver.js +8 -2
- package/lib/helper/extras/Download.js +45 -0
- package/lib/helper/extras/richTextEditor.js +115 -0
- package/lib/history.js +3 -2
- package/lib/listener/config.js +6 -4
- package/lib/listener/emptyRun.js +2 -1
- package/lib/listener/helpers.js +4 -1
- package/lib/listener/mocha.js +2 -1
- package/lib/listener/pageobjects.js +43 -0
- package/lib/listener/result.js +3 -2
- package/lib/locator.js +112 -0
- package/lib/mocha/cli.js +4 -2
- package/lib/mocha/factory.js +2 -1
- package/lib/mocha/scenarioConfig.js +2 -1
- package/lib/mocha/ui.js +5 -6
- package/lib/plugin/aiTrace.js +4 -3
- package/lib/plugin/analyze.js +1 -1
- package/lib/plugin/auth.js +3 -3
- package/lib/plugin/pageInfo.js +2 -1
- package/lib/plugin/pauseOn.js +167 -0
- package/lib/plugin/screenshotOnFail.js +3 -4
- package/lib/plugin/stepByStepReport.js +5 -4
- package/lib/rerun.js +2 -1
- package/lib/result.js +2 -1
- package/lib/step/base.js +3 -2
- package/lib/step/record.js +1 -1
- package/lib/store.js +72 -3
- package/lib/translation.js +2 -1
- package/lib/utils/mask_data.js +2 -1
- package/lib/utils.js +4 -3
- package/lib/workers.js +2 -0
- package/package.json +5 -3
package/lib/listener/mocha.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import event from '../event.js'
|
|
2
|
+
import container from '../container.js'
|
|
2
3
|
|
|
3
4
|
export default function () {
|
|
4
5
|
let mocha
|
|
5
6
|
|
|
6
7
|
event.dispatcher.on(event.all.before, () => {
|
|
7
|
-
mocha =
|
|
8
|
+
mocha = container.mocha()
|
|
8
9
|
})
|
|
9
10
|
|
|
10
11
|
event.dispatcher.on(event.test.passed, test => {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import event from '../event.js'
|
|
2
|
+
import recorder from '../recorder.js'
|
|
3
|
+
import store from '../store.js'
|
|
4
|
+
import container from '../container.js'
|
|
5
|
+
import { resetBeforeCalledSet, getBeforeCalledSet } from '../container.js'
|
|
6
|
+
|
|
7
|
+
export default function () {
|
|
8
|
+
const runAsyncSupportHook = (hook, param, force) => {
|
|
9
|
+
if (store.dryRun) return
|
|
10
|
+
const support = container.supportObjects()
|
|
11
|
+
Object.keys(support).forEach(key => {
|
|
12
|
+
if (key === 'I') return
|
|
13
|
+
const obj = support[key]
|
|
14
|
+
if (!obj || typeof obj !== 'object' || !obj[hook]) return
|
|
15
|
+
recorder.add(`pageobject ${key}.${hook}()`, () => obj[hook](param), force, false)
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
event.dispatcher.on(event.test.started, () => {
|
|
20
|
+
resetBeforeCalledSet()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
event.dispatcher.on(event.test.after, () => {
|
|
24
|
+
if (store.dryRun) return
|
|
25
|
+
const support = container.supportObjects()
|
|
26
|
+
const called = getBeforeCalledSet()
|
|
27
|
+
called.forEach(name => {
|
|
28
|
+
const obj = support[name]
|
|
29
|
+
if (obj && obj._after) {
|
|
30
|
+
recorder.add(`pageobject ${name}._after()`, () => obj._after(), true, false)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
recorder.catchWithoutStop(() => {})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
event.dispatcher.on(event.suite.after, suite => {
|
|
37
|
+
runAsyncSupportHook('_afterSuite', suite, true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
event.dispatcher.on(event.suite.before, suite => {
|
|
41
|
+
runAsyncSupportHook('_beforeSuite', suite, true)
|
|
42
|
+
})
|
|
43
|
+
}
|
package/lib/listener/result.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import event from '../event.js'
|
|
2
|
+
import container from '../container.js'
|
|
2
3
|
|
|
3
4
|
export default function () {
|
|
4
5
|
event.dispatcher.on(event.hook.failed, err => {
|
|
5
|
-
|
|
6
|
+
container.result().addStats({ failedHooks: 1 })
|
|
6
7
|
})
|
|
7
8
|
|
|
8
9
|
event.dispatcher.on(event.test.before, test => {
|
|
9
|
-
|
|
10
|
+
container.result().addTest(test)
|
|
10
11
|
})
|
|
11
12
|
}
|
package/lib/locator.js
CHANGED
|
@@ -381,9 +381,121 @@ class Locator {
|
|
|
381
381
|
return new Locator({ xpath })
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Find an element with all of the provided CSS classes (word-exact match).
|
|
386
|
+
* Accepts variadic class names; all must be present.
|
|
387
|
+
*
|
|
388
|
+
* Example:
|
|
389
|
+
* locate('button').withClass('btn-primary', 'btn-lg')
|
|
390
|
+
*
|
|
391
|
+
* @param {...string} classes
|
|
392
|
+
* @returns {Locator}
|
|
393
|
+
*/
|
|
394
|
+
withClass(...classes) {
|
|
395
|
+
if (!classes.length) return this
|
|
396
|
+
const predicates = classes.map(c => `contains(concat(' ', normalize-space(@class), ' '), ' ${c} ')`)
|
|
397
|
+
const xpath = sprintf('%s[%s]', this.toXPath(), predicates.join(' and '))
|
|
398
|
+
return new Locator({ xpath })
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Find an element with none of the provided CSS classes.
|
|
403
|
+
*
|
|
404
|
+
* Example:
|
|
405
|
+
* locate('tr').withoutClass('deleted')
|
|
406
|
+
*
|
|
407
|
+
* @param {...string} classes
|
|
408
|
+
* @returns {Locator}
|
|
409
|
+
*/
|
|
410
|
+
withoutClass(...classes) {
|
|
411
|
+
if (!classes.length) return this
|
|
412
|
+
const predicates = classes.map(c => `not(contains(concat(' ', normalize-space(@class), ' '), ' ${c} '))`)
|
|
413
|
+
const xpath = sprintf('%s[%s]', this.toXPath(), predicates.join(' and '))
|
|
414
|
+
return new Locator({ xpath })
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Find an element that does NOT contain the provided text.
|
|
419
|
+
* @param {string} text
|
|
420
|
+
* @returns {Locator}
|
|
421
|
+
*/
|
|
422
|
+
withoutText(text) {
|
|
423
|
+
text = xpathLocator.literal(text)
|
|
424
|
+
const xpath = sprintf('%s[%s]', this.toXPath(), `not(contains(., ${text}))`)
|
|
425
|
+
return new Locator({ xpath })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Find an element that does NOT have any of the provided attribute/value pairs.
|
|
430
|
+
* @param {Object.<string, string>} attributes
|
|
431
|
+
* @returns {Locator}
|
|
432
|
+
*/
|
|
433
|
+
withoutAttr(attributes) {
|
|
434
|
+
const operands = []
|
|
435
|
+
for (const attr of Object.keys(attributes)) {
|
|
436
|
+
operands.push(`not(@${attr} = ${xpathLocator.literal(attributes[attr])})`)
|
|
437
|
+
}
|
|
438
|
+
const xpath = sprintf('%s[%s]', this.toXPath(), operands.join(' and '))
|
|
439
|
+
return new Locator({ xpath })
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Find an element that has no direct child matching the provided locator.
|
|
444
|
+
* @param {CodeceptJS.LocatorOrString} locator
|
|
445
|
+
* @returns {Locator}
|
|
446
|
+
*/
|
|
447
|
+
withoutChild(locator) {
|
|
448
|
+
const xpath = sprintf('%s[not(./child::%s)]', this.toXPath(), convertToSubSelector(locator))
|
|
449
|
+
return new Locator({ xpath })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Find an element that has no descendant matching the provided locator.
|
|
454
|
+
*
|
|
455
|
+
* Example:
|
|
456
|
+
* locate('button').withoutDescendant('svg')
|
|
457
|
+
*
|
|
458
|
+
* @param {CodeceptJS.LocatorOrString} locator
|
|
459
|
+
* @returns {Locator}
|
|
460
|
+
*/
|
|
461
|
+
withoutDescendant(locator) {
|
|
462
|
+
const xpath = sprintf('%s[not(./descendant::%s)]', this.toXPath(), convertToSubSelector(locator))
|
|
463
|
+
return new Locator({ xpath })
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Append a raw XPath predicate. Escape hatch for expressions not covered by the DSL.
|
|
468
|
+
* Argument is inserted as-is inside `[ ]`; quoting/escaping is the caller's responsibility.
|
|
469
|
+
*
|
|
470
|
+
* Example:
|
|
471
|
+
* locate('input').and('@type="text" or @type="email"')
|
|
472
|
+
*
|
|
473
|
+
* @param {string} xpathExpression
|
|
474
|
+
* @returns {Locator}
|
|
475
|
+
*/
|
|
476
|
+
and(xpathExpression) {
|
|
477
|
+
const xpath = sprintf('%s[%s]', this.toXPath(), xpathExpression)
|
|
478
|
+
return new Locator({ xpath })
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Append a negated raw XPath predicate: `[not(expr)]`.
|
|
483
|
+
*
|
|
484
|
+
* Example:
|
|
485
|
+
* locate('button').andNot('.//svg') // button without a descendant svg
|
|
486
|
+
*
|
|
487
|
+
* @param {string} xpathExpression
|
|
488
|
+
* @returns {Locator}
|
|
489
|
+
*/
|
|
490
|
+
andNot(xpathExpression) {
|
|
491
|
+
const xpath = sprintf('%s[not(%s)]', this.toXPath(), xpathExpression)
|
|
492
|
+
return new Locator({ xpath })
|
|
493
|
+
}
|
|
494
|
+
|
|
384
495
|
/**
|
|
385
496
|
* @param {String} text
|
|
386
497
|
* @returns {Locator}
|
|
498
|
+
* @deprecated Use {@link Locator#withClass} for word-exact class matching, or {@link Locator#withAttrContains} for substring matching.
|
|
387
499
|
*/
|
|
388
500
|
withClassAttr(text) {
|
|
389
501
|
const xpath = sprintf('%s[%s]', this.toXPath(), `contains(@class, '${text}')`)
|
package/lib/mocha/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { dirname, join } from 'path'
|
|
|
8
8
|
import event from '../event.js'
|
|
9
9
|
import AssertionFailedError from '../assert/error.js'
|
|
10
10
|
import output from '../output.js'
|
|
11
|
+
import store from '../store.js'
|
|
11
12
|
import test, { cloneTest } from './test.js'
|
|
12
13
|
import { fixErrorStack } from '../utils/typescript.js'
|
|
13
14
|
|
|
@@ -41,7 +42,7 @@ class Cli extends Base {
|
|
|
41
42
|
if (opts.verbose) level = 3
|
|
42
43
|
output.level(level)
|
|
43
44
|
output.print(`CodeceptJS v${codeceptVersion} ${output.standWithUkraine()}`)
|
|
44
|
-
output.print(`Using test root "${
|
|
45
|
+
output.print(`Using test root "${store.codeceptDir}"`)
|
|
45
46
|
|
|
46
47
|
const showSteps = level >= 1
|
|
47
48
|
|
|
@@ -213,6 +214,7 @@ class Cli extends Base {
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
// append step traces
|
|
217
|
+
const Container = await getContainer()
|
|
216
218
|
this.failures = this.failures.map(test => {
|
|
217
219
|
// we will change the stack trace, so we need to clone the test
|
|
218
220
|
const err = test.err
|
|
@@ -275,7 +277,7 @@ class Cli extends Base {
|
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
try {
|
|
278
|
-
const fileMapping =
|
|
280
|
+
const fileMapping = Container?.tsFileMapping?.()
|
|
279
281
|
if (fileMapping) {
|
|
280
282
|
fixErrorStack(err, fileMapping)
|
|
281
283
|
}
|
package/lib/mocha/factory.js
CHANGED
|
@@ -8,6 +8,7 @@ import output from '../output.js'
|
|
|
8
8
|
import scenarioUiFunction from './ui.js'
|
|
9
9
|
import { initMochaGlobals } from '../globals.js'
|
|
10
10
|
import { fixErrorStack } from '../utils/typescript.js'
|
|
11
|
+
import container from '../container.js'
|
|
11
12
|
|
|
12
13
|
const __filename = fileURLToPath(import.meta.url)
|
|
13
14
|
const __dirname = fsPath.dirname(__filename)
|
|
@@ -35,7 +36,7 @@ class MochaFactory {
|
|
|
35
36
|
// Handle ECONNREFUSED without dynamic import for now
|
|
36
37
|
err = new Error('Connection refused: ' + err.toString())
|
|
37
38
|
}
|
|
38
|
-
const fileMapping =
|
|
39
|
+
const fileMapping = container?.tsFileMapping?.()
|
|
39
40
|
if (fileMapping) {
|
|
40
41
|
fixErrorStack(err, fileMapping)
|
|
41
42
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isAsyncFunction } from '../utils.js'
|
|
2
|
+
import store from '../store.js'
|
|
2
3
|
|
|
3
4
|
/** @class */
|
|
4
5
|
class ScenarioConfig {
|
|
@@ -40,7 +41,7 @@ class ScenarioConfig {
|
|
|
40
41
|
* @returns {this}
|
|
41
42
|
*/
|
|
42
43
|
retry(retries) {
|
|
43
|
-
if (
|
|
44
|
+
if (store.scenarioOnly) retries = -retries
|
|
44
45
|
this.test.retries(retries)
|
|
45
46
|
return this
|
|
46
47
|
}
|
package/lib/mocha/ui.js
CHANGED
|
@@ -9,13 +9,12 @@ import { HookConfig, AfterSuiteHook, AfterHook, BeforeSuiteHook, BeforeHook } fr
|
|
|
9
9
|
import { initMochaGlobals } from '../globals.js'
|
|
10
10
|
import common from 'mocha/lib/interfaces/common.js'
|
|
11
11
|
import container from '../container.js'
|
|
12
|
+
import store from '../store.js'
|
|
12
13
|
|
|
13
14
|
const setContextTranslation = context => {
|
|
14
|
-
|
|
15
|
-
const containerToUse = global.container || container
|
|
16
|
-
if (!containerToUse) return
|
|
15
|
+
if (!container) return
|
|
17
16
|
|
|
18
|
-
const translation =
|
|
17
|
+
const translation = container.translation?.() || container.translation
|
|
19
18
|
const contexts = translation?.value?.('contexts')
|
|
20
19
|
|
|
21
20
|
if (contexts) {
|
|
@@ -119,7 +118,7 @@ export default function (suite) {
|
|
|
119
118
|
context.Feature.only = function (title, opts) {
|
|
120
119
|
const reString = `^${escapeRe(`${title}:`)}`
|
|
121
120
|
mocha.grep(new RegExp(reString))
|
|
122
|
-
|
|
121
|
+
store.featureOnly = true
|
|
123
122
|
return context.Feature(title, opts)
|
|
124
123
|
}
|
|
125
124
|
|
|
@@ -171,7 +170,7 @@ export default function (suite) {
|
|
|
171
170
|
context.Scenario.only = function (title, opts, fn) {
|
|
172
171
|
const reString = `^${escapeRe(`${suites[0].title}: ${title}`.replace(/( \| {.+})?$/g, ''))}`
|
|
173
172
|
mocha.grep(new RegExp(reString))
|
|
174
|
-
|
|
173
|
+
store.scenarioOnly = true
|
|
175
174
|
return addScenario(title, opts, fn)
|
|
176
175
|
}
|
|
177
176
|
|
package/lib/plugin/aiTrace.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdirp } from 'mkdirp'
|
|
|
4
4
|
import path from 'path'
|
|
5
5
|
import { fileURLToPath } from 'url'
|
|
6
6
|
|
|
7
|
+
import store from '../store.js'
|
|
7
8
|
import Container from '../container.js'
|
|
8
9
|
import recorder from '../recorder.js'
|
|
9
10
|
import event from '../event.js'
|
|
@@ -16,7 +17,7 @@ const supportedHelpers = Container.STANDARD_ACTING_HELPERS
|
|
|
16
17
|
const defaultConfig = {
|
|
17
18
|
deleteSuccessful: false,
|
|
18
19
|
fullPageScreenshots: false,
|
|
19
|
-
output:
|
|
20
|
+
output: store.outputDir,
|
|
20
21
|
captureHTML: true,
|
|
21
22
|
captureARIA: true,
|
|
22
23
|
captureBrowserLogs: true,
|
|
@@ -84,7 +85,7 @@ export default function (config) {
|
|
|
84
85
|
let testFailed = false
|
|
85
86
|
let firstFailedStepSaved = false
|
|
86
87
|
|
|
87
|
-
const reportDir = config.output ? path.resolve(
|
|
88
|
+
const reportDir = config.output ? path.resolve(store.codeceptDir, config.output) : defaultConfig.output
|
|
88
89
|
|
|
89
90
|
if (config.captureDebugOutput) {
|
|
90
91
|
const originalDebug = output.debug
|
|
@@ -437,7 +438,7 @@ export default function (config) {
|
|
|
437
438
|
const traceFile = path.join(dir, 'trace.md')
|
|
438
439
|
fs.writeFileSync(traceFile, markdown)
|
|
439
440
|
|
|
440
|
-
output.print(
|
|
441
|
+
output.print(`Trace Saved: file://${traceFile}`)
|
|
441
442
|
|
|
442
443
|
if (!test.artifacts) test.artifacts = {}
|
|
443
444
|
test.artifacts.aiTrace = traceFile
|
package/lib/plugin/analyze.js
CHANGED
package/lib/plugin/auth.js
CHANGED
|
@@ -321,7 +321,7 @@ export default function (config) {
|
|
|
321
321
|
}
|
|
322
322
|
if (config.saveToFile) {
|
|
323
323
|
output.debug(`Saved user session into file for ${name}`)
|
|
324
|
-
fs.writeFileSync(path.join(
|
|
324
|
+
fs.writeFileSync(path.join(store.outputDir, `${name}_session.json`), JSON.stringify(cookies))
|
|
325
325
|
}
|
|
326
326
|
store[`${name}_session`] = cookies
|
|
327
327
|
}
|
|
@@ -377,7 +377,7 @@ export default function (config) {
|
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
if (!config.saveToFile) return
|
|
380
|
-
const cookieFile = path.join(
|
|
380
|
+
const cookieFile = path.join(store.outputDir, `${name}_session.json`)
|
|
381
381
|
|
|
382
382
|
if (!fileExists(cookieFile)) {
|
|
383
383
|
return
|
|
@@ -412,7 +412,7 @@ export default function (config) {
|
|
|
412
412
|
|
|
413
413
|
function loadCookiesFromFile(config) {
|
|
414
414
|
for (const name in config.users) {
|
|
415
|
-
const fileName = path.join(
|
|
415
|
+
const fileName = path.join(store.outputDir, `${name}_session.json`)
|
|
416
416
|
if (!fileExists(fileName)) continue
|
|
417
417
|
const data = fs.readFileSync(fileName).toString()
|
|
418
418
|
try {
|
package/lib/plugin/pageInfo.js
CHANGED
|
@@ -6,6 +6,7 @@ import recorder from '../recorder.js'
|
|
|
6
6
|
import event from '../event.js'
|
|
7
7
|
import { scanForErrorMessages } from '../html.js'
|
|
8
8
|
import { output } from '../index.js'
|
|
9
|
+
import store from '../store.js'
|
|
9
10
|
import { humanizeString, ucfirst } from '../utils.js'
|
|
10
11
|
import { testToFileName } from '../mocha/test.js'
|
|
11
12
|
const defaultConfig = {
|
|
@@ -92,7 +93,7 @@ export default function (config = {}) {
|
|
|
92
93
|
recorder.add('Save page info', () => {
|
|
93
94
|
test.addNote('pageInfo', pageStateToMarkdown(pageState))
|
|
94
95
|
|
|
95
|
-
const pageStateFileName = path.join(
|
|
96
|
+
const pageStateFileName = path.join(store.outputDir, `${testToFileName(test)}.pageInfo.md`)
|
|
96
97
|
fs.writeFileSync(pageStateFileName, pageStateToMarkdown(pageState))
|
|
97
98
|
test.artifacts.pageInfo = pageStateFileName
|
|
98
99
|
return pageState
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import event from '../event.js'
|
|
2
|
+
import pause from '../pause.js'
|
|
3
|
+
import recorder from '../recorder.js'
|
|
4
|
+
import Container from '../container.js'
|
|
5
|
+
import output from '../output.js'
|
|
6
|
+
|
|
7
|
+
const supportedHelpers = Container.STANDARD_ACTING_HELPERS
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pauses test execution in different modes. Unlike `pauseOnFail`, this plugin supports
|
|
11
|
+
* multiple triggers for pausing and is controlled via CLI arguments.
|
|
12
|
+
*
|
|
13
|
+
* Enable it via `-p` option with a mode:
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* npx codeceptjs run -p pauseOn:fail
|
|
17
|
+
* npx codeceptjs run -p pauseOn:step
|
|
18
|
+
* npx codeceptjs run -p pauseOn:file:tests/login_test.js
|
|
19
|
+
* npx codeceptjs run -p pauseOn:file:tests/login_test.js:43
|
|
20
|
+
* npx codeceptjs run -p pauseOn:url:/users/*
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* #### Modes
|
|
24
|
+
*
|
|
25
|
+
* * **fail** — pause when a step fails (same as `pauseOnFail` plugin)
|
|
26
|
+
* * **step** — pause before first step, use `next` to advance step-by-step
|
|
27
|
+
* * **file** — pause when execution reaches a specific file (and optionally line)
|
|
28
|
+
* * **url** — pause when the browser URL matches a pattern (supports `*` wildcards)
|
|
29
|
+
*
|
|
30
|
+
*/
|
|
31
|
+
export default function (config = {}) {
|
|
32
|
+
const args = config._args || []
|
|
33
|
+
const mode = args[0] || 'fail'
|
|
34
|
+
|
|
35
|
+
switch (mode) {
|
|
36
|
+
case 'fail':
|
|
37
|
+
return initFailMode()
|
|
38
|
+
case 'step':
|
|
39
|
+
return initStepMode()
|
|
40
|
+
case 'file':
|
|
41
|
+
return initFileMode(args.slice(1))
|
|
42
|
+
case 'url':
|
|
43
|
+
return initUrlMode(args.slice(1))
|
|
44
|
+
default:
|
|
45
|
+
output.error(`pauseOn: unknown mode "${mode}". Available: fail, step, file, url`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function initFailMode() {
|
|
50
|
+
let failed = false
|
|
51
|
+
|
|
52
|
+
event.dispatcher.on(event.test.started, () => {
|
|
53
|
+
failed = false
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
event.dispatcher.on(event.step.failed, () => {
|
|
57
|
+
failed = true
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
event.dispatcher.on(event.test.after, () => {
|
|
61
|
+
if (failed) pause()
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function initStepMode() {
|
|
66
|
+
let activated = false
|
|
67
|
+
|
|
68
|
+
event.dispatcher.on(event.test.before, () => {
|
|
69
|
+
if (activated) return
|
|
70
|
+
activated = true
|
|
71
|
+
recorder.add('pauseOn:step', () => pause())
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function initFileMode(fileArgs) {
|
|
76
|
+
if (fileArgs.length === 0) {
|
|
77
|
+
output.error('pauseOn:file requires a path. Usage: -p pauseOn:file:<path>[:<line>]')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const targetFile = fileArgs[0]
|
|
82
|
+
const targetLine = fileArgs[1] ? parseInt(fileArgs[1], 10) : null
|
|
83
|
+
let paused = false
|
|
84
|
+
|
|
85
|
+
event.dispatcher.on(event.step.before, (step) => {
|
|
86
|
+
if (paused) return
|
|
87
|
+
|
|
88
|
+
const stepLine = step.line()
|
|
89
|
+
if (!stepLine) return
|
|
90
|
+
|
|
91
|
+
const match = parseStepLine(stepLine)
|
|
92
|
+
if (!match) return
|
|
93
|
+
|
|
94
|
+
const fileMatches = match.file.includes(targetFile) || match.file.endsWith(targetFile)
|
|
95
|
+
if (!fileMatches) return
|
|
96
|
+
|
|
97
|
+
if (targetLine !== null && match.line !== targetLine) return
|
|
98
|
+
|
|
99
|
+
paused = true
|
|
100
|
+
recorder.add('pauseOn:file', () => pause())
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function initUrlMode(urlArgs) {
|
|
105
|
+
if (urlArgs.length === 0) {
|
|
106
|
+
output.error('pauseOn:url requires a pattern. Usage: -p pauseOn:url:<pattern>')
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const urlPattern = urlArgs.join(':')
|
|
111
|
+
|
|
112
|
+
const helpers = Container.helpers()
|
|
113
|
+
let helper = null
|
|
114
|
+
for (const helperName of supportedHelpers) {
|
|
115
|
+
if (Object.keys(helpers).indexOf(helperName) > -1) {
|
|
116
|
+
helper = helpers[helperName]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!helper) {
|
|
121
|
+
output.error('pauseOn:url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium)')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const regex = patternToRegex(urlPattern)
|
|
126
|
+
let paused = false
|
|
127
|
+
|
|
128
|
+
event.dispatcher.on(event.step.after, () => {
|
|
129
|
+
if (paused) return
|
|
130
|
+
|
|
131
|
+
recorder.add('pauseOn:url check', async () => {
|
|
132
|
+
if (paused) return
|
|
133
|
+
try {
|
|
134
|
+
const currentUrl = await helper.grabCurrentUrl()
|
|
135
|
+
if (regex.test(currentUrl)) {
|
|
136
|
+
paused = true
|
|
137
|
+
return pause()
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// page may not be loaded yet
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseStepLine(stepLine) {
|
|
147
|
+
let line = stepLine.trim()
|
|
148
|
+
if (line.startsWith('at ')) line = line.substring(3).trim()
|
|
149
|
+
|
|
150
|
+
const lastColon = line.lastIndexOf(':')
|
|
151
|
+
if (lastColon < 0) return null
|
|
152
|
+
const secondLastColon = line.lastIndexOf(':', lastColon - 1)
|
|
153
|
+
if (secondLastColon < 0) return null
|
|
154
|
+
|
|
155
|
+
const file = line.substring(0, secondLastColon)
|
|
156
|
+
const lineNum = parseInt(line.substring(secondLastColon + 1, lastColon), 10)
|
|
157
|
+
|
|
158
|
+
if (isNaN(lineNum)) return null
|
|
159
|
+
|
|
160
|
+
return { file, line: lineNum }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function patternToRegex(pattern) {
|
|
164
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
165
|
+
const regexStr = escaped.replace(/\*/g, '.*')
|
|
166
|
+
return new RegExp(regexStr)
|
|
167
|
+
}
|
|
@@ -8,6 +8,7 @@ import recorder from '../recorder.js'
|
|
|
8
8
|
import event from '../event.js'
|
|
9
9
|
|
|
10
10
|
import output from '../output.js'
|
|
11
|
+
import store from '../store.js'
|
|
11
12
|
|
|
12
13
|
import { fileExists } from '../utils.js'
|
|
13
14
|
import Codeceptjs from '../index.js'
|
|
@@ -94,7 +95,7 @@ export default function (config) {
|
|
|
94
95
|
} else {
|
|
95
96
|
fileName = `${testToFileName(test, { suffix: '', unique: false })}.failed.png`
|
|
96
97
|
}
|
|
97
|
-
const quietMode = !
|
|
98
|
+
const quietMode = !store.outputDir
|
|
98
99
|
if (!quietMode) {
|
|
99
100
|
output.plugin('screenshotOnFail', 'Test failed, try to save a screenshot')
|
|
100
101
|
}
|
|
@@ -139,9 +140,7 @@ export default function (config) {
|
|
|
139
140
|
await Promise.race([screenshotPromise, timeoutPromise])
|
|
140
141
|
|
|
141
142
|
if (!test.artifacts) test.artifacts = {}
|
|
142
|
-
|
|
143
|
-
// Detect output directory safely (may not be initialized in narrow unit tests)
|
|
144
|
-
const baseOutputDir = 'output_dir' in global && typeof global.output_dir === 'string' && global.output_dir ? global.output_dir : null
|
|
143
|
+
const baseOutputDir = store.outputDir || null
|
|
145
144
|
if (baseOutputDir) {
|
|
146
145
|
test.artifacts.screenshot = path.join(baseOutputDir, fileName)
|
|
147
146
|
if (Container.mocha().options.reporterOptions['mocha-junit-reporter'] && Container.mocha().options.reporterOptions['mocha-junit-reporter'].options.attachments) {
|
|
@@ -4,8 +4,9 @@ import figures from 'figures'
|
|
|
4
4
|
import fs from 'fs'
|
|
5
5
|
import { mkdirp } from 'mkdirp'
|
|
6
6
|
import path from 'path'
|
|
7
|
-
import cheerio from 'cheerio'
|
|
7
|
+
import * as cheerio from 'cheerio'
|
|
8
8
|
|
|
9
|
+
import store from '../store.js'
|
|
9
10
|
import Container from '../container.js'
|
|
10
11
|
import recorder from '../recorder.js'
|
|
11
12
|
import event from '../event.js'
|
|
@@ -19,7 +20,7 @@ const defaultConfig = {
|
|
|
19
20
|
animateSlides: true,
|
|
20
21
|
ignoreSteps: [],
|
|
21
22
|
fullPageScreenshots: false,
|
|
22
|
-
output:
|
|
23
|
+
output: store.outputDir,
|
|
23
24
|
screenshotsForAllureReport: false,
|
|
24
25
|
disableScreenshotOnFail: true,
|
|
25
26
|
}
|
|
@@ -87,7 +88,7 @@ export default function (config) {
|
|
|
87
88
|
|
|
88
89
|
const recordedTests = {}
|
|
89
90
|
const pad = '0000'
|
|
90
|
-
const reportDir = config.output ? path.resolve(
|
|
91
|
+
const reportDir = config.output ? path.resolve(store.codeceptDir, config.output) : defaultConfig.output
|
|
91
92
|
|
|
92
93
|
event.dispatcher.on(event.suite.before, suite => {
|
|
93
94
|
stepNum = -1
|
|
@@ -198,7 +199,7 @@ export default function (config) {
|
|
|
198
199
|
// Ignore steps from BeforeSuite function
|
|
199
200
|
if (scenarioFailed && config.disableScreenshotOnFail) return
|
|
200
201
|
if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
|
|
201
|
-
if (!
|
|
202
|
+
if (!currentTest) return // Ignore steps from AfterSuite
|
|
202
203
|
|
|
203
204
|
const fileName = `${pad.substring(0, pad.length - stepNum.toString().length) + stepNum.toString()}.png`
|
|
204
205
|
if (step.status === 'failed') {
|
package/lib/rerun.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fsPath from 'path'
|
|
2
|
+
import store from './store.js'
|
|
2
3
|
import container from './container.js'
|
|
3
4
|
import event from './event.js'
|
|
4
5
|
import BaseCodecept from './codecept.js'
|
|
@@ -29,7 +30,7 @@ class CodeceptRerunner extends BaseCodecept {
|
|
|
29
30
|
let filesToRun = this.testFiles
|
|
30
31
|
if (test) {
|
|
31
32
|
if (!fsPath.isAbsolute(test)) {
|
|
32
|
-
test = fsPath.join(
|
|
33
|
+
test = fsPath.join(store.codeceptDir, test)
|
|
33
34
|
}
|
|
34
35
|
filesToRun = this.testFiles.filter(t => fsPath.basename(t, '.js') === test || t === test)
|
|
35
36
|
}
|
package/lib/result.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import { serializeTest } from './mocha/test.js'
|
|
4
|
+
import store from './store.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @typedef {Object} Stats Statistics for a test result.
|
|
@@ -212,7 +213,7 @@ class Result {
|
|
|
212
213
|
*/
|
|
213
214
|
save(fileName) {
|
|
214
215
|
if (!fileName) fileName = 'result.json'
|
|
215
|
-
fs.writeFileSync(path.join(
|
|
216
|
+
fs.writeFileSync(path.join(store.outputDir, fileName), JSON.stringify(this.simplify(), null, 2))
|
|
216
217
|
}
|
|
217
218
|
|
|
218
219
|
/**
|
package/lib/step/base.js
CHANGED
|
@@ -3,6 +3,7 @@ import Secret from '../secret.js'
|
|
|
3
3
|
import { getCurrentTimeout } from '../timeout.js'
|
|
4
4
|
import { ucfirst, humanizeString, serializeError } from '../utils.js'
|
|
5
5
|
import recordStep from './record.js'
|
|
6
|
+
import store from '../store.js'
|
|
6
7
|
|
|
7
8
|
const STACK_LINE = 5
|
|
8
9
|
|
|
@@ -148,11 +149,11 @@ class Step {
|
|
|
148
149
|
const lines = this.stack.split('\n')
|
|
149
150
|
if (lines[STACK_LINE]) {
|
|
150
151
|
let line = lines[STACK_LINE].trim()
|
|
151
|
-
.replace(
|
|
152
|
+
.replace(store.codeceptDir || '', '.')
|
|
152
153
|
.trim()
|
|
153
154
|
|
|
154
155
|
// Map .temp.mjs back to original .ts files using container's tsFileMapping
|
|
155
|
-
const fileMapping =
|
|
156
|
+
const fileMapping = store.tsFileMapping
|
|
156
157
|
if (line.includes('.temp.mjs') && fileMapping) {
|
|
157
158
|
for (const [tsFile, mjsFile] of fileMapping.entries()) {
|
|
158
159
|
if (line.includes(mjsFile)) {
|