codeceptjs 4.0.0-rc.2 → 4.0.0-rc.21
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/README.md +39 -27
- package/bin/codecept.js +15 -2
- package/bin/codeceptq.js +49 -0
- package/bin/mcp-server.js +1189 -0
- package/docs/advanced.md +201 -0
- package/docs/agents.md +181 -0
- package/docs/ai.md +537 -0
- package/docs/aitrace.md +266 -0
- package/docs/api.md +332 -0
- package/docs/assertions.md +415 -0
- package/docs/auth.md +318 -0
- package/docs/basics.md +424 -0
- package/docs/bdd.md +539 -0
- package/docs/best.md +240 -0
- package/docs/bootstrap.md +132 -0
- package/docs/commands.md +352 -0
- package/docs/community-helpers.md +63 -0
- package/docs/configuration.md +230 -0
- package/docs/continuous-integration.md +497 -0
- package/docs/custom-helpers.md +297 -0
- package/docs/data.md +448 -0
- package/docs/debugging.md +332 -0
- package/docs/detox.md +235 -0
- package/docs/docker.md +136 -0
- package/docs/effects.md +179 -0
- package/docs/element-based-testing.md +295 -0
- package/docs/element-selection.md +125 -0
- package/docs/els.md +328 -0
- package/docs/environment-variables.md +131 -0
- package/docs/examples.md +161 -0
- package/docs/heal.md +213 -0
- package/docs/helpers/ApiDataFactory.md +267 -0
- package/docs/helpers/Appium.md +1405 -0
- package/docs/helpers/Detox.md +665 -0
- package/docs/helpers/ExpectHelper.md +275 -0
- package/docs/helpers/FileSystem.md +152 -0
- package/docs/helpers/GraphQL.md +152 -0
- package/docs/helpers/GraphQLDataFactory.md +226 -0
- package/docs/helpers/JSONResponse.md +255 -0
- package/docs/helpers/Mochawesome.md +8 -0
- package/docs/helpers/MockRequest.md +377 -0
- package/docs/helpers/MockServer.md +212 -0
- package/docs/helpers/Playwright.md +2969 -0
- package/docs/helpers/Polly.md +44 -0
- package/docs/helpers/Protractor.md +1769 -0
- package/docs/helpers/Puppeteer-firefox.md +86 -0
- package/docs/helpers/Puppeteer.md +2690 -0
- package/docs/helpers/REST.md +289 -0
- package/docs/helpers/SoftExpectHelper.md +352 -0
- package/docs/helpers/WebDriver.md +2682 -0
- package/docs/hooks.md +339 -0
- package/docs/index.md +111 -0
- package/docs/installation.md +83 -0
- package/docs/internal-api.md +265 -0
- package/docs/internal-test-server.md +89 -0
- package/docs/locators.md +355 -0
- package/docs/mcp.md +485 -0
- package/docs/migration-4.md +556 -0
- package/docs/mobile.md +338 -0
- package/docs/pageobjects.md +399 -0
- package/docs/parallel.md +585 -0
- package/docs/playwright.md +714 -0
- package/docs/plugins.md +866 -0
- package/docs/puppeteer.md +314 -0
- package/docs/quickstart.md +120 -0
- package/docs/react.md +70 -0
- package/docs/reports.md +483 -0
- package/docs/retry.md +274 -0
- package/docs/secrets.md +150 -0
- package/docs/sessions.md +80 -0
- package/docs/shadow.md +68 -0
- package/docs/test-structure.md +275 -0
- package/docs/timeouts.md +183 -0
- package/docs/translation.md +247 -0
- package/docs/tutorial.md +271 -0
- package/docs/typescript.md +374 -0
- package/docs/web-element.md +251 -0
- package/docs/webdriver.md +708 -0
- package/docs/within.md +55 -0
- package/lib/ai.js +3 -2
- package/lib/aria.js +260 -0
- package/lib/assertions.js +18 -0
- package/lib/codecept.js +27 -24
- package/lib/command/check.js +2 -1
- package/lib/command/dryRun.js +24 -5
- package/lib/command/generate.js +2 -0
- package/lib/command/gherkin/snippets.js +5 -4
- package/lib/command/init.js +248 -269
- package/lib/command/list.js +150 -10
- package/lib/command/query.js +218 -0
- package/lib/command/run-multiple.js +2 -0
- package/lib/command/run-workers.js +2 -14
- package/lib/command/run.js +3 -17
- package/lib/command/utils.js +14 -0
- package/lib/command/workers/runTests.js +10 -10
- package/lib/config.js +77 -4
- package/lib/container.js +114 -17
- package/lib/effects.js +17 -0
- package/lib/element/WebElement.js +246 -2
- package/lib/els.js +12 -6
- package/lib/globals.js +32 -19
- package/lib/heal.js +6 -3
- package/lib/helper/ApiDataFactory.js +2 -1
- package/lib/helper/Appium.js +8 -8
- package/lib/helper/FileSystem.js +3 -2
- package/lib/helper/GraphQLDataFactory.js +2 -1
- package/lib/helper/Playwright.js +233 -162
- package/lib/helper/Puppeteer.js +208 -76
- package/lib/helper/WebDriver.js +173 -68
- package/lib/helper/errors/MultipleElementsFound.js +27 -110
- package/lib/helper/errors/NonFocusedType.js +8 -0
- package/lib/helper/extras/Download.js +45 -0
- package/lib/helper/extras/PlaywrightReactVueLocator.js +45 -36
- package/lib/helper/extras/elementSelection.js +58 -0
- package/lib/helper/extras/focusCheck.js +43 -0
- package/lib/helper/extras/richTextEditor.js +178 -0
- package/lib/helper/scripts/dropFile.js +11 -0
- package/lib/history.js +3 -2
- package/lib/html.js +103 -16
- package/lib/index.js +9 -1
- package/lib/listener/config.js +6 -4
- package/lib/listener/emptyRun.js +2 -1
- package/lib/listener/globalRetry.js +32 -6
- 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 +126 -3
- package/lib/mocha/cli.js +14 -2
- package/lib/mocha/factory.js +7 -2
- package/lib/mocha/inject.js +1 -1
- package/lib/mocha/scenarioConfig.js +2 -1
- package/lib/mocha/ui.js +5 -6
- package/lib/parser.js +2 -2
- package/lib/pause.js +38 -4
- package/lib/plugin/aiTrace.js +456 -0
- package/lib/plugin/analyze.js +6 -5
- package/lib/plugin/auth.js +3 -3
- package/lib/plugin/browser.js +77 -0
- package/lib/plugin/expose.js +159 -0
- package/lib/plugin/heal.js +47 -3
- package/lib/plugin/pageInfo.js +54 -52
- package/lib/plugin/pause.js +131 -0
- package/lib/plugin/pauseOnFail.js +10 -34
- package/lib/plugin/retryFailedStep.js +32 -22
- package/lib/plugin/screencast.js +289 -0
- package/lib/plugin/screenshot.js +563 -0
- package/lib/plugin/screenshotOnFail.js +8 -171
- package/lib/rerun.js +2 -1
- package/lib/result.js +2 -1
- package/lib/step/base.js +3 -2
- package/lib/step/config.js +15 -2
- package/lib/step/record.js +2 -2
- package/lib/store.js +72 -3
- package/lib/translation.js +2 -1
- package/lib/utils/mask_data.js +2 -1
- package/lib/utils/pluginParser.js +151 -0
- package/lib/utils/trace.js +297 -0
- package/lib/utils.js +77 -3
- package/lib/workers.js +63 -25
- package/package.json +19 -13
- package/typings/index.d.ts +19 -5
- package/docs/webapi/amOnPage.mustache +0 -11
- package/docs/webapi/appendField.mustache +0 -11
- package/docs/webapi/attachFile.mustache +0 -12
- package/docs/webapi/blur.mustache +0 -18
- package/docs/webapi/checkOption.mustache +0 -13
- package/docs/webapi/clearCookie.mustache +0 -9
- package/docs/webapi/clearField.mustache +0 -9
- package/docs/webapi/click.mustache +0 -29
- package/docs/webapi/clickLink.mustache +0 -8
- package/docs/webapi/closeCurrentTab.mustache +0 -7
- package/docs/webapi/closeOtherTabs.mustache +0 -8
- package/docs/webapi/dontSee.mustache +0 -11
- package/docs/webapi/dontSeeCheckboxIsChecked.mustache +0 -10
- package/docs/webapi/dontSeeCookie.mustache +0 -8
- package/docs/webapi/dontSeeCurrentPathEquals.mustache +0 -10
- package/docs/webapi/dontSeeCurrentUrlEquals.mustache +0 -10
- package/docs/webapi/dontSeeElement.mustache +0 -8
- package/docs/webapi/dontSeeElementInDOM.mustache +0 -8
- package/docs/webapi/dontSeeInCurrentUrl.mustache +0 -4
- package/docs/webapi/dontSeeInField.mustache +0 -11
- package/docs/webapi/dontSeeInSource.mustache +0 -8
- package/docs/webapi/dontSeeInTitle.mustache +0 -8
- package/docs/webapi/dontSeeTraffic.mustache +0 -13
- package/docs/webapi/doubleClick.mustache +0 -13
- package/docs/webapi/downloadFile.mustache +0 -12
- package/docs/webapi/dragAndDrop.mustache +0 -9
- package/docs/webapi/dragSlider.mustache +0 -11
- package/docs/webapi/executeAsyncScript.mustache +0 -24
- package/docs/webapi/executeScript.mustache +0 -26
- package/docs/webapi/fillField.mustache +0 -16
- package/docs/webapi/flushNetworkTraffics.mustache +0 -5
- package/docs/webapi/focus.mustache +0 -13
- package/docs/webapi/forceClick.mustache +0 -28
- package/docs/webapi/forceRightClick.mustache +0 -18
- package/docs/webapi/grabAllWindowHandles.mustache +0 -7
- package/docs/webapi/grabAttributeFrom.mustache +0 -10
- package/docs/webapi/grabAttributeFromAll.mustache +0 -9
- package/docs/webapi/grabBrowserLogs.mustache +0 -9
- package/docs/webapi/grabCookie.mustache +0 -11
- package/docs/webapi/grabCssPropertyFrom.mustache +0 -11
- package/docs/webapi/grabCssPropertyFromAll.mustache +0 -10
- package/docs/webapi/grabCurrentUrl.mustache +0 -9
- package/docs/webapi/grabCurrentWindowHandle.mustache +0 -6
- package/docs/webapi/grabDataFromPerformanceTiming.mustache +0 -20
- package/docs/webapi/grabElementBoundingRect.mustache +0 -20
- package/docs/webapi/grabGeoLocation.mustache +0 -8
- package/docs/webapi/grabHTMLFrom.mustache +0 -10
- package/docs/webapi/grabHTMLFromAll.mustache +0 -9
- package/docs/webapi/grabNumberOfOpenTabs.mustache +0 -8
- package/docs/webapi/grabNumberOfVisibleElements.mustache +0 -9
- package/docs/webapi/grabPageScrollPosition.mustache +0 -8
- package/docs/webapi/grabPopupText.mustache +0 -5
- package/docs/webapi/grabRecordedNetworkTraffics.mustache +0 -10
- package/docs/webapi/grabSource.mustache +0 -8
- package/docs/webapi/grabTextFrom.mustache +0 -10
- package/docs/webapi/grabTextFromAll.mustache +0 -9
- package/docs/webapi/grabTitle.mustache +0 -8
- package/docs/webapi/grabValueFrom.mustache +0 -9
- package/docs/webapi/grabValueFromAll.mustache +0 -8
- package/docs/webapi/grabWebElement.mustache +0 -9
- package/docs/webapi/grabWebElements.mustache +0 -9
- package/docs/webapi/moveCursorTo.mustache +0 -12
- package/docs/webapi/openNewTab.mustache +0 -7
- package/docs/webapi/pressKey.mustache +0 -12
- package/docs/webapi/pressKeyDown.mustache +0 -12
- package/docs/webapi/pressKeyUp.mustache +0 -12
- package/docs/webapi/pressKeyWithKeyNormalization.mustache +0 -60
- package/docs/webapi/refreshPage.mustache +0 -6
- package/docs/webapi/resizeWindow.mustache +0 -6
- package/docs/webapi/rightClick.mustache +0 -14
- package/docs/webapi/saveElementScreenshot.mustache +0 -10
- package/docs/webapi/saveScreenshot.mustache +0 -12
- package/docs/webapi/say.mustache +0 -10
- package/docs/webapi/scrollIntoView.mustache +0 -11
- package/docs/webapi/scrollPageToBottom.mustache +0 -6
- package/docs/webapi/scrollPageToTop.mustache +0 -6
- package/docs/webapi/scrollTo.mustache +0 -12
- package/docs/webapi/see.mustache +0 -11
- package/docs/webapi/seeAttributesOnElements.mustache +0 -9
- package/docs/webapi/seeCheckboxIsChecked.mustache +0 -10
- package/docs/webapi/seeCookie.mustache +0 -8
- package/docs/webapi/seeCssPropertiesOnElements.mustache +0 -9
- package/docs/webapi/seeCurrentPathEquals.mustache +0 -10
- package/docs/webapi/seeCurrentUrlEquals.mustache +0 -11
- package/docs/webapi/seeElement.mustache +0 -8
- package/docs/webapi/seeElementInDOM.mustache +0 -8
- package/docs/webapi/seeInCurrentUrl.mustache +0 -8
- package/docs/webapi/seeInField.mustache +0 -12
- package/docs/webapi/seeInPopup.mustache +0 -8
- package/docs/webapi/seeInSource.mustache +0 -7
- package/docs/webapi/seeInTitle.mustache +0 -8
- package/docs/webapi/seeNumberOfElements.mustache +0 -11
- package/docs/webapi/seeNumberOfVisibleElements.mustache +0 -10
- package/docs/webapi/seeTextEquals.mustache +0 -9
- package/docs/webapi/seeTitleEquals.mustache +0 -8
- package/docs/webapi/seeTraffic.mustache +0 -36
- package/docs/webapi/selectOption.mustache +0 -21
- package/docs/webapi/setCookie.mustache +0 -16
- package/docs/webapi/setGeoLocation.mustache +0 -12
- package/docs/webapi/startRecordingTraffic.mustache +0 -8
- package/docs/webapi/startRecordingWebSocketMessages.mustache +0 -8
- package/docs/webapi/stopRecordingTraffic.mustache +0 -5
- package/docs/webapi/stopRecordingWebSocketMessages.mustache +0 -7
- package/docs/webapi/switchTo.mustache +0 -9
- package/docs/webapi/switchToNextTab.mustache +0 -10
- package/docs/webapi/switchToPreviousTab.mustache +0 -10
- package/docs/webapi/type.mustache +0 -21
- package/docs/webapi/uncheckOption.mustache +0 -13
- package/docs/webapi/wait.mustache +0 -8
- package/docs/webapi/waitForClickable.mustache +0 -11
- package/docs/webapi/waitForCookie.mustache +0 -9
- package/docs/webapi/waitForDetached.mustache +0 -10
- package/docs/webapi/waitForDisabled.mustache +0 -6
- package/docs/webapi/waitForElement.mustache +0 -11
- package/docs/webapi/waitForEnabled.mustache +0 -6
- package/docs/webapi/waitForFunction.mustache +0 -17
- package/docs/webapi/waitForInvisible.mustache +0 -10
- package/docs/webapi/waitForNumberOfTabs.mustache +0 -9
- package/docs/webapi/waitForText.mustache +0 -13
- package/docs/webapi/waitForValue.mustache +0 -10
- package/docs/webapi/waitForVisible.mustache +0 -10
- package/docs/webapi/waitInUrl.mustache +0 -9
- package/docs/webapi/waitNumberOfVisibleElements.mustache +0 -10
- package/docs/webapi/waitToHide.mustache +0 -10
- package/docs/webapi/waitUrlEquals.mustache +0 -10
- package/lib/helper/AI.js +0 -214
- package/lib/listener/enhancedGlobalRetry.js +0 -110
- package/lib/plugin/enhancedRetryFailedStep.js +0 -99
- package/lib/plugin/htmlReporter.js +0 -3648
- package/lib/plugin/stepByStepReport.js +0 -427
- package/lib/plugin/subtitles.js +0 -89
- package/lib/retryCoordinator.js +0 -207
- package/typings/promiseBasedTypes.d.ts +0 -9469
- package/typings/types.d.ts +0 -11402
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { mkdirp } from 'mkdirp'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
import store from '../store.js'
|
|
6
|
+
import Container from '../container.js'
|
|
7
|
+
import recorder from '../recorder.js'
|
|
8
|
+
import event from '../event.js'
|
|
9
|
+
import output from '../output.js'
|
|
10
|
+
import { deleteDir, clearString } from '../utils.js'
|
|
11
|
+
import { captureSnapshot, pickActingHelper, traceDirFor, artifactLinks } from '../utils/trace.js'
|
|
12
|
+
import {
|
|
13
|
+
parsePluginArgs,
|
|
14
|
+
resolveTrigger,
|
|
15
|
+
matchStepFile,
|
|
16
|
+
matchUrl,
|
|
17
|
+
} from '../utils/pluginParser.js'
|
|
18
|
+
|
|
19
|
+
const defaultConfig = {
|
|
20
|
+
on: 'step',
|
|
21
|
+
deleteSuccessful: false,
|
|
22
|
+
fullPageScreenshots: false,
|
|
23
|
+
output: store.outputDir,
|
|
24
|
+
captureHTML: true,
|
|
25
|
+
captureARIA: true,
|
|
26
|
+
captureBrowserLogs: true,
|
|
27
|
+
captureHTTP: true,
|
|
28
|
+
captureDebugOutput: true,
|
|
29
|
+
ignoreSteps: [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
*
|
|
34
|
+
* Generates AI-friendly trace files for debugging with AI agents.
|
|
35
|
+
* This plugin creates a markdown file with test execution logs and links to all artifacts
|
|
36
|
+
* (screenshots, HTML, ARIA snapshots, browser logs, HTTP requests) for each step.
|
|
37
|
+
*
|
|
38
|
+
* #### Configuration
|
|
39
|
+
*
|
|
40
|
+
* ```js
|
|
41
|
+
* "plugins": {
|
|
42
|
+
* "aiTrace": {
|
|
43
|
+
* "enabled": true
|
|
44
|
+
* }
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* Possible config options:
|
|
49
|
+
*
|
|
50
|
+
* * `deleteSuccessful`: delete traces for successfully executed tests. Default: false.
|
|
51
|
+
* * `fullPageScreenshots`: should full page screenshots be used. Default: false.
|
|
52
|
+
* * `output`: a directory where traces should be stored. Default: `output`.
|
|
53
|
+
* * `captureHTML`: capture HTML for each step. Default: true.
|
|
54
|
+
* * `captureARIA`: capture ARIA snapshot for each step. Default: true.
|
|
55
|
+
* * `captureBrowserLogs`: capture browser console logs. Default: true.
|
|
56
|
+
* * `captureHTTP`: capture HTTP requests (requires `trace` or `recordHar` enabled in helper config). Default: true.
|
|
57
|
+
* * `captureDebugOutput`: capture CodeceptJS debug output. Default: true.
|
|
58
|
+
* * `ignoreSteps`: steps to ignore in trace. Array of RegExps is expected.
|
|
59
|
+
* * `on`: trigger mode — `step` (default), `fail`, `test`, `file`, `url`.
|
|
60
|
+
*
|
|
61
|
+
* #### `on=` modes
|
|
62
|
+
*
|
|
63
|
+
* * **step** — persist every step (default)
|
|
64
|
+
* * **fail** — persist only the failed step
|
|
65
|
+
* * **test** — persist only the last step of each test
|
|
66
|
+
* * **file** — persist steps from `path=...[;line=...]`
|
|
67
|
+
* * **url** — persist when the current URL matches `pattern=...`
|
|
68
|
+
*
|
|
69
|
+
* @param {*} config
|
|
70
|
+
*/
|
|
71
|
+
export default function (config = {}) {
|
|
72
|
+
const cliArgs = parsePluginArgs(config._args)
|
|
73
|
+
const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, { name: 'aiTrace' })
|
|
74
|
+
if (!trigger) return
|
|
75
|
+
|
|
76
|
+
config = Object.assign(defaultConfig, config)
|
|
77
|
+
|
|
78
|
+
const helper = pickActingHelper(Container.helpers())
|
|
79
|
+
|
|
80
|
+
if (!helper) {
|
|
81
|
+
output.warn('aiTrace plugin: No supported helper found (Playwright, Puppeteer, WebDriver). Plugin disabled.')
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let dir
|
|
86
|
+
let stepNum
|
|
87
|
+
let steps = []
|
|
88
|
+
let debugOutput = []
|
|
89
|
+
let error
|
|
90
|
+
let savedSteps = new Set()
|
|
91
|
+
let currentTest = null
|
|
92
|
+
let testStartTime
|
|
93
|
+
let currentUrl = null
|
|
94
|
+
let testFailed = false
|
|
95
|
+
let pendingArtifactCapture = null
|
|
96
|
+
let firstFailedStepSaved = false
|
|
97
|
+
|
|
98
|
+
const reportDir = config.output ? path.resolve(store.codeceptDir, config.output) : defaultConfig.output
|
|
99
|
+
|
|
100
|
+
if (config.captureDebugOutput) {
|
|
101
|
+
const originalDebug = output.debug
|
|
102
|
+
output.debug = function (...args) {
|
|
103
|
+
debugOutput.push(args.join(' '))
|
|
104
|
+
originalDebug.apply(output, args)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
event.dispatcher.on(event.suite.before, suite => {
|
|
109
|
+
stepNum = -1
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
event.dispatcher.on(event.test.before, test => {
|
|
113
|
+
let title
|
|
114
|
+
try {
|
|
115
|
+
title = test.fullTitle ? test.fullTitle() : test.title
|
|
116
|
+
} catch (err) {
|
|
117
|
+
title = test.title
|
|
118
|
+
}
|
|
119
|
+
dir = traceDirFor(test.file, title, reportDir)
|
|
120
|
+
mkdirp.sync(dir)
|
|
121
|
+
deleteDir(dir)
|
|
122
|
+
mkdirp.sync(dir)
|
|
123
|
+
stepNum = 0
|
|
124
|
+
error = null
|
|
125
|
+
steps = []
|
|
126
|
+
debugOutput = []
|
|
127
|
+
savedSteps.clear()
|
|
128
|
+
currentTest = test
|
|
129
|
+
testStartTime = Date.now()
|
|
130
|
+
currentUrl = null
|
|
131
|
+
testFailed = false
|
|
132
|
+
firstFailedStepSaved = false
|
|
133
|
+
pendingArtifactCapture = null
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
event.dispatcher.on(event.step.after, step => {
|
|
137
|
+
if (!currentTest) return
|
|
138
|
+
if (step.status === 'failed') {
|
|
139
|
+
testFailed = true
|
|
140
|
+
}
|
|
141
|
+
if (step.status === 'queued' && testFailed) {
|
|
142
|
+
output.debug(`aiTrace: Skipping queued step "${step.toString()}" - testFailed: ${testFailed}`)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (step.status === 'failed' && firstFailedStepSaved) {
|
|
146
|
+
output.debug(`aiTrace: Skipping failed step "${step.toString()}" - already handled by step.failed event`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// on= filtering
|
|
151
|
+
if (trigger.on === 'fail') return // failed steps handled by step.failed
|
|
152
|
+
if (trigger.on === 'file' && !matchStepFile(step, trigger.path, trigger.line)) return
|
|
153
|
+
if (trigger.on === 'url') {
|
|
154
|
+
recorder.add('aiTrace:url check', async () => {
|
|
155
|
+
try {
|
|
156
|
+
if (!helper.grabCurrentUrl) return
|
|
157
|
+
const url = await helper.grabCurrentUrl()
|
|
158
|
+
if (!matchUrl(url, trigger.pattern)) return
|
|
159
|
+
await persistStep(step)
|
|
160
|
+
} catch (err) {
|
|
161
|
+
output.debug(`aiTrace: Error in url-mode step persistence: ${err.message}`)
|
|
162
|
+
}
|
|
163
|
+
}, true)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
recorder.add(`aiTrace step persistence: ${step.toString()}`, () => persistStep(step).catch(err => {
|
|
168
|
+
output.debug(`aiTrace: Error saving step: ${err.message}`)
|
|
169
|
+
}), true)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
event.dispatcher.on(event.step.failed, step => {
|
|
173
|
+
if (!currentTest) return
|
|
174
|
+
if (step.status === 'queued' && testFailed) {
|
|
175
|
+
output.debug(`aiTrace: Skipping queued failed step "${step.toString()}" - testFailed: ${testFailed}`)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (firstFailedStepSaved) {
|
|
179
|
+
output.debug(`aiTrace: Skipping subsequent failed step "${step.toString()}" - already saved first failed step`)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const stepKey = step.toString()
|
|
184
|
+
if (savedSteps.has(stepKey)) {
|
|
185
|
+
const existingStep = steps.find(s => s.step === stepKey)
|
|
186
|
+
if (!existingStep) {
|
|
187
|
+
output.debug(`aiTrace: Step "${stepKey}" marked as saved but not found in steps array`)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
existingStep.status = 'failed'
|
|
191
|
+
|
|
192
|
+
pendingArtifactCapture = captureArtifactsForStep(step, existingStep, existingStep.prefix).catch(err => {
|
|
193
|
+
output.debug(`aiTrace: Error updating failed step: ${err.message}`)
|
|
194
|
+
})
|
|
195
|
+
} else {
|
|
196
|
+
if (stepNum === -1) return
|
|
197
|
+
if (isStepIgnored(step)) return
|
|
198
|
+
if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
|
|
199
|
+
|
|
200
|
+
const stepPrefix = generateStepPrefix(step, stepNum)
|
|
201
|
+
stepNum++
|
|
202
|
+
|
|
203
|
+
const stepData = {
|
|
204
|
+
step: stepKey,
|
|
205
|
+
status: 'failed',
|
|
206
|
+
prefix: stepPrefix,
|
|
207
|
+
artifacts: {},
|
|
208
|
+
meta: {},
|
|
209
|
+
debugOutput: [],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (step.startTime && step.endTime) {
|
|
213
|
+
stepData.meta.duration = ((step.endTime - step.startTime) / 1000).toFixed(2) + 's'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
savedSteps.add(stepKey)
|
|
217
|
+
steps.push(stepData)
|
|
218
|
+
firstFailedStepSaved = true
|
|
219
|
+
|
|
220
|
+
pendingArtifactCapture = captureArtifactsForStep(step, stepData, stepPrefix).catch(err => {
|
|
221
|
+
output.debug(`aiTrace: Error capturing failed step artifacts: ${err.message}`)
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
event.dispatcher.on(event.test.passed, test => {
|
|
227
|
+
if (config.deleteSuccessful) {
|
|
228
|
+
deleteDir(dir)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
persist(test, 'passed')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
|
|
235
|
+
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') {
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
recorder.add('aiTrace:persist failed', async () => {
|
|
239
|
+
if (pendingArtifactCapture) {
|
|
240
|
+
await pendingArtifactCapture
|
|
241
|
+
pendingArtifactCapture = null
|
|
242
|
+
}
|
|
243
|
+
persist(test, 'failed')
|
|
244
|
+
}, true)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
async function persistStep(step) {
|
|
248
|
+
if (stepNum === -1) return
|
|
249
|
+
if (isStepIgnored(step)) return
|
|
250
|
+
if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
|
|
251
|
+
|
|
252
|
+
const stepKey = step.toString()
|
|
253
|
+
|
|
254
|
+
if (savedSteps.has(stepKey)) {
|
|
255
|
+
const existingStep = steps.find(s => s.step === stepKey)
|
|
256
|
+
if (existingStep && step.status === 'failed') {
|
|
257
|
+
existingStep.status = 'failed'
|
|
258
|
+
step.artifacts = {}
|
|
259
|
+
await captureArtifactsForStep(step, existingStep, existingStep.prefix)
|
|
260
|
+
}
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
savedSteps.add(stepKey)
|
|
264
|
+
|
|
265
|
+
const stepPrefix = generateStepPrefix(step, stepNum)
|
|
266
|
+
stepNum++
|
|
267
|
+
|
|
268
|
+
const stepData = {
|
|
269
|
+
step: step.toString(),
|
|
270
|
+
status: step.status,
|
|
271
|
+
prefix: stepPrefix,
|
|
272
|
+
artifacts: {},
|
|
273
|
+
meta: {},
|
|
274
|
+
debugOutput: [],
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (step.startTime && step.endTime) {
|
|
278
|
+
stepData.meta.duration = ((step.endTime - step.startTime) / 1000).toFixed(2) + 's'
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (config.captureDebugOutput && debugOutput.length > 0) {
|
|
282
|
+
stepData.debugOutput = [...debugOutput]
|
|
283
|
+
debugOutput = []
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await captureArtifactsForStep(step, stepData, stepPrefix)
|
|
287
|
+
steps.push(stepData)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function captureArtifactsForStep(step, stepData, stepPrefix) {
|
|
291
|
+
if (!step.artifacts) {
|
|
292
|
+
step.artifacts = {}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let browserAvailable = true
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
try {
|
|
299
|
+
if (helper.grabCurrentUrl) {
|
|
300
|
+
const url = await helper.grabCurrentUrl()
|
|
301
|
+
stepData.meta.url = url
|
|
302
|
+
currentUrl = url
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
browserAvailable = false
|
|
306
|
+
output.debug(`aiTrace: Browser unavailable, partial artifact capture: ${err.message}`)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let preExistingScreenshot = false
|
|
310
|
+
if (step.artifacts?.screenshot) {
|
|
311
|
+
const screenshotPath = path.isAbsolute(step.artifacts.screenshot)
|
|
312
|
+
? step.artifacts.screenshot
|
|
313
|
+
: path.resolve(dir, step.artifacts.screenshot)
|
|
314
|
+
const screenshotFile = path.basename(screenshotPath)
|
|
315
|
+
stepData.artifacts.screenshot = screenshotFile
|
|
316
|
+
step.artifacts.screenshot = screenshotPath
|
|
317
|
+
preExistingScreenshot = true
|
|
318
|
+
|
|
319
|
+
if (!fs.existsSync(screenshotPath)) {
|
|
320
|
+
try {
|
|
321
|
+
await helper.saveScreenshot(screenshotPath, config.fullPageScreenshots)
|
|
322
|
+
} catch (err) {
|
|
323
|
+
output.debug(`aiTrace: Could not save screenshot: ${err.message}`)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const captured = await captureSnapshot(helper, {
|
|
329
|
+
dir,
|
|
330
|
+
prefix: stepPrefix,
|
|
331
|
+
fullPage: config.fullPageScreenshots,
|
|
332
|
+
captureHTML: config.captureHTML && browserAvailable,
|
|
333
|
+
captureARIA: config.captureARIA && browserAvailable,
|
|
334
|
+
captureBrowserLogs: config.captureBrowserLogs && browserAvailable,
|
|
335
|
+
captureStorage: false,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
if (!preExistingScreenshot && captured.screenshot) {
|
|
339
|
+
stepData.artifacts.screenshot = captured.screenshot
|
|
340
|
+
step.artifacts.screenshot = path.join(dir, captured.screenshot)
|
|
341
|
+
}
|
|
342
|
+
if (step.artifacts?.html) {
|
|
343
|
+
stepData.artifacts.html = step.artifacts.html
|
|
344
|
+
} else if (captured.html) {
|
|
345
|
+
stepData.artifacts.html = captured.html
|
|
346
|
+
}
|
|
347
|
+
if (captured.aria) stepData.artifacts.aria = captured.aria
|
|
348
|
+
if (captured.console) {
|
|
349
|
+
stepData.artifacts.console = captured.console
|
|
350
|
+
stepData.meta.consoleCount = captured.consoleCount
|
|
351
|
+
}
|
|
352
|
+
} catch (err) {
|
|
353
|
+
output.plugin(`aiTrace: Can't save step artifacts: ${err}`)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function persist(test, status) {
|
|
358
|
+
if (!steps.length) {
|
|
359
|
+
output.debug('aiTrace: No steps to save in trace')
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// on=test: only render the last step in markdown; artifacts of earlier steps
|
|
364
|
+
// remain on disk unreferenced.
|
|
365
|
+
if (trigger.on === 'test') {
|
|
366
|
+
steps = steps.slice(-1)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const testDuration = ((Date.now() - testStartTime) / 1000).toFixed(2)
|
|
370
|
+
|
|
371
|
+
let markdown = `file: ${test.file || 'unknown'}\n`
|
|
372
|
+
markdown += `name: ${test.title}\n`
|
|
373
|
+
markdown += `time: ${testDuration}s\n`
|
|
374
|
+
markdown += `---\n\n`
|
|
375
|
+
|
|
376
|
+
if (status === 'failed') {
|
|
377
|
+
if (test.art && test.art.message) {
|
|
378
|
+
markdown += `Error: ${test.art.message}\n\n`
|
|
379
|
+
}
|
|
380
|
+
if (test.art && test.art.stack) {
|
|
381
|
+
markdown += `${test.art.stack}\n\n`
|
|
382
|
+
}
|
|
383
|
+
markdown += `---\n\n`
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (config.captureDebugOutput && debugOutput.length > 0) {
|
|
387
|
+
markdown += `CodeceptJS Debug Output:\n\n`
|
|
388
|
+
debugOutput.forEach(line => {
|
|
389
|
+
markdown += `> ${line}\n`
|
|
390
|
+
})
|
|
391
|
+
markdown += `\n---\n\n`
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
steps.forEach((stepData, index) => {
|
|
395
|
+
const stepAnchor = clearString(stepData.step).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50)
|
|
396
|
+
markdown += `### Step ${index + 1}: ${stepData.step}\n`
|
|
397
|
+
markdown += `<a id="${stepAnchor}"></a>\n`
|
|
398
|
+
|
|
399
|
+
if (stepData.meta.duration) {
|
|
400
|
+
markdown += ` > duration: ${stepData.meta.duration}\n`
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (stepData.meta.url) {
|
|
404
|
+
markdown += ` > navigated to ${stepData.meta.url}\n`
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (config.captureDebugOutput && stepData.debugOutput && stepData.debugOutput.length > 0) {
|
|
408
|
+
stepData.debugOutput.forEach(line => {
|
|
409
|
+
markdown += ` > ${line}\n`
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const links = artifactLinks(stepData.artifacts, { consoleCount: stepData.meta.consoleCount })
|
|
414
|
+
if (links) markdown += links + '\n'
|
|
415
|
+
|
|
416
|
+
if (config.captureHTTP) {
|
|
417
|
+
if (test.artifacts && test.artifacts.har) {
|
|
418
|
+
const harPath = path.relative(reportDir, test.artifacts.har)
|
|
419
|
+
markdown += ` > HTTP: see [HAR file](../${harPath}) for network requests\n`
|
|
420
|
+
} else if (test.artifacts && test.artifacts.trace) {
|
|
421
|
+
const tracePath = path.relative(reportDir, test.artifacts.trace)
|
|
422
|
+
markdown += ` > HTTP: see [Playwright trace](../${tracePath}) for network requests\n`
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
markdown += `\n`
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
const traceFile = path.join(dir, 'trace.md')
|
|
430
|
+
fs.writeFileSync(traceFile, markdown)
|
|
431
|
+
|
|
432
|
+
output.print(`Trace Saved: file://${traceFile}`)
|
|
433
|
+
|
|
434
|
+
if (!test.artifacts) test.artifacts = {}
|
|
435
|
+
test.artifacts.aiTrace = traceFile
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isStepIgnored(step) {
|
|
439
|
+
if (!config.ignoreSteps) return false
|
|
440
|
+
for (const pattern of config.ignoreSteps || []) {
|
|
441
|
+
if (step.name.match(pattern)) return true
|
|
442
|
+
}
|
|
443
|
+
return false
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function generateStepPrefix(step, index) {
|
|
447
|
+
const stepName = step.toString()
|
|
448
|
+
const cleanedName = clearString(stepName)
|
|
449
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
450
|
+
.replace(/_{2,}/g, '_')
|
|
451
|
+
.slice(0, 80)
|
|
452
|
+
.trim()
|
|
453
|
+
|
|
454
|
+
return `${String(index).padStart(4, '0')}_${cleanedName}`
|
|
455
|
+
}
|
|
456
|
+
}
|
package/lib/plugin/analyze.js
CHANGED
|
@@ -12,6 +12,7 @@ const ai = aiModule.default || aiModule
|
|
|
12
12
|
import colors from 'chalk'
|
|
13
13
|
import ora from 'ora'
|
|
14
14
|
import event from '../event.js'
|
|
15
|
+
import recorder from '../recorder.js'
|
|
15
16
|
|
|
16
17
|
import output from '../output.js'
|
|
17
18
|
|
|
@@ -227,14 +228,14 @@ export default function (config = {}) {
|
|
|
227
228
|
console.log('Enabled AI analysis')
|
|
228
229
|
})
|
|
229
230
|
|
|
230
|
-
event.dispatcher.on(event.all.result,
|
|
231
|
+
event.dispatcher.on(event.all.result, result => {
|
|
231
232
|
if (!isMainThread) return // run only on main thread
|
|
232
233
|
if (!ai.isEnabled) {
|
|
233
234
|
console.log('AI is disabled, no analysis will be performed. Run tests with --ai flag to enable it.')
|
|
234
235
|
return
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
printReport(result)
|
|
238
|
+
recorder.add('analyze:print-ai-report', () => printReport(result), true)
|
|
238
239
|
})
|
|
239
240
|
|
|
240
241
|
event.dispatcher.on(event.workers.result, async result => {
|
|
@@ -248,7 +249,7 @@ export default function (config = {}) {
|
|
|
248
249
|
return
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
printReport(result)
|
|
252
|
+
await printReport(result)
|
|
252
253
|
})
|
|
253
254
|
|
|
254
255
|
async function printReport(result) {
|
|
@@ -294,7 +295,7 @@ export default function (config = {}) {
|
|
|
294
295
|
console.error('Error analyzing failed tests', err)
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
if (!Object.keys(
|
|
298
|
+
if (!Object.keys(Container.plugins()).includes('pageInfo')) {
|
|
298
299
|
console.log('To improve analysis, enable pageInfo plugin to get more context for failed tests.')
|
|
299
300
|
}
|
|
300
301
|
}
|
|
@@ -353,7 +354,7 @@ function serializeError(error) {
|
|
|
353
354
|
errorMessage +=
|
|
354
355
|
'\n' +
|
|
355
356
|
error.stack
|
|
356
|
-
.replace(
|
|
357
|
+
.replace(store.codeceptDir || '', '.')
|
|
357
358
|
.split('\n')
|
|
358
359
|
.map(line => line.replace(ansiRegExp(), ''))
|
|
359
360
|
.slice(0, 5)
|
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 {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import output from '../output.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Overrides browser helper config from the command line. Works for all browser helpers
|
|
5
|
+
* (Playwright, Puppeteer, WebDriver, Appium) without touching `codecept.conf`.
|
|
6
|
+
*
|
|
7
|
+
* Enable it via `-p` option with one or more colon-chained args:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* npx codeceptjs run -p browser:show
|
|
11
|
+
* npx codeceptjs run -p browser:hide
|
|
12
|
+
* npx codeceptjs run -p browser:browser=firefox
|
|
13
|
+
* npx codeceptjs run -p browser:windowSize=1024x768:video=false
|
|
14
|
+
* npx codeceptjs run -p browser:hide:browser=webkit:windowSize=800x600
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* #### Args
|
|
18
|
+
*
|
|
19
|
+
* * **show** — force visible browser
|
|
20
|
+
* * **hide** — force headless (also injects `--headless` into WebDriver chrome/firefox capability args)
|
|
21
|
+
* * **`<key>=<value>`** — set `helpers.<eachBrowserHelper>.<key> = <value>`. Three keys
|
|
22
|
+
* get per-helper translation via `setBrowserConfig`:
|
|
23
|
+
* * `browser=<name>` — Puppeteer receives `product`, Playwright receives `browser`
|
|
24
|
+
* * `windowSize=WxH` — also adds `--window-size=W,H` chromium/chrome args
|
|
25
|
+
* * `show=true|false` — toggles `show` on Playwright/Puppeteer; injects/strips
|
|
26
|
+
* `--headless` in WebDriver chrome/firefox capability args
|
|
27
|
+
*
|
|
28
|
+
* Values stay as strings. `true` / `false` are coerced to booleans.
|
|
29
|
+
*
|
|
30
|
+
* Requires `@codeceptjs/configure` to be installed; if missing, the plugin
|
|
31
|
+
* logs a hint and skips the override.
|
|
32
|
+
*/
|
|
33
|
+
export default async function (config = {}) {
|
|
34
|
+
const { _args, enabled, ...rest } = config
|
|
35
|
+
const opts = { ...rest, ...parseArgs(_args || []) }
|
|
36
|
+
if (Object.keys(opts).length === 0) return
|
|
37
|
+
|
|
38
|
+
const configure = await tryImportConfigure()
|
|
39
|
+
if (!configure) return
|
|
40
|
+
|
|
41
|
+
configure.setBrowserConfig(opts)
|
|
42
|
+
output.debug(`browser plugin: applied ${formatOpts(opts)}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function tryImportConfigure() {
|
|
46
|
+
try {
|
|
47
|
+
return await import('@codeceptjs/configure')
|
|
48
|
+
} catch (err) {
|
|
49
|
+
output.error("browser plugin: '@codeceptjs/configure' is not installed; CLI overrides are skipped. Run `npm i @codeceptjs/configure` to enable.")
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseArgs(args) {
|
|
55
|
+
return args.filter(Boolean).reduce((acc, arg) => Object.assign(acc, parseArg(arg)), {})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseArg(arg) {
|
|
59
|
+
if (arg === 'show') return { show: true }
|
|
60
|
+
if (arg === 'hide') return { show: false }
|
|
61
|
+
if (arg.includes('=')) {
|
|
62
|
+
const [key, ...rest] = arg.split('=')
|
|
63
|
+
return { [key]: parseValue(rest.join('=')) }
|
|
64
|
+
}
|
|
65
|
+
output.error(`browser plugin: unknown arg "${arg}"`)
|
|
66
|
+
return {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseValue(v) {
|
|
70
|
+
if (v === 'true') return true
|
|
71
|
+
if (v === 'false') return false
|
|
72
|
+
return v
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatOpts(opts) {
|
|
76
|
+
return Object.entries(opts).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
77
|
+
}
|