codeceptjs 4.0.0-rc.2 → 4.0.0-rc.20
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 +1187 -0
- package/docs/advanced.md +201 -0
- package/docs/agents.md +159 -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/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 +26 -23
- 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 -0
- package/lib/command/run.js +1 -1
- 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 +4 -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 +228 -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 +453 -0
- package/lib/plugin/analyze.js +1 -1
- 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 +44 -1
- package/lib/plugin/pageInfo.js +53 -49
- package/lib/plugin/pause.js +131 -0
- package/lib/plugin/pauseOnFail.js +10 -34
- package/lib/plugin/retryFailedStep.js +28 -19
- package/lib/plugin/screencast.js +287 -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 +52 -22
- 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,563 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { mkdirp } from 'mkdirp'
|
|
5
|
+
|
|
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 store from '../store.js'
|
|
11
|
+
|
|
12
|
+
import { fileExists, deleteDir, template } from '../utils.js'
|
|
13
|
+
import Codeceptjs from '../index.js'
|
|
14
|
+
import { testToFileName } from '../mocha/test.js'
|
|
15
|
+
import {
|
|
16
|
+
parsePluginArgs,
|
|
17
|
+
resolveTrigger,
|
|
18
|
+
matchStepFile,
|
|
19
|
+
matchUrl,
|
|
20
|
+
getBrowserHelper,
|
|
21
|
+
} from '../utils/pluginParser.js'
|
|
22
|
+
|
|
23
|
+
const defaultConfig = {
|
|
24
|
+
on: 'fail',
|
|
25
|
+
slides: false,
|
|
26
|
+
uniqueScreenshotNames: false,
|
|
27
|
+
disableScreenshots: false,
|
|
28
|
+
fullPageScreenshots: false,
|
|
29
|
+
animateSlides: true,
|
|
30
|
+
deleteSuccessful: true,
|
|
31
|
+
ignoreSteps: [],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Saves screenshots from the browser at points triggered by `on=`.
|
|
36
|
+
*
|
|
37
|
+
* Replaces the legacy `screenshotOnFail` plugin. Default `on=fail` preserves the
|
|
38
|
+
* old behavior (screenshot when a test fails). Pass `slides=true` (with `on=step`)
|
|
39
|
+
* to generate a step-by-step slideshow report — replaces the legacy
|
|
40
|
+
* `stepByStepReport` plugin.
|
|
41
|
+
*
|
|
42
|
+
* #### Configuration
|
|
43
|
+
*
|
|
44
|
+
* ```js
|
|
45
|
+
* plugins: {
|
|
46
|
+
* screenshot: {
|
|
47
|
+
* enabled: true,
|
|
48
|
+
* on: 'fail',
|
|
49
|
+
* }
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* #### `on=` modes
|
|
54
|
+
*
|
|
55
|
+
* * **fail** — screenshot when a test fails (default)
|
|
56
|
+
* * **test** — screenshot at the end of every test
|
|
57
|
+
* * **step** — screenshot after every step
|
|
58
|
+
* * **file** — screenshot for steps in `path=...[;line=...]`
|
|
59
|
+
* * **url** — screenshot when the current browser URL matches `pattern=...`
|
|
60
|
+
*
|
|
61
|
+
* Other config options:
|
|
62
|
+
*
|
|
63
|
+
* * `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
|
|
64
|
+
* * `fullPageScreenshots`: make full page screenshots. Default: false.
|
|
65
|
+
* * `disableScreenshots`: legacy switch to skip the plugin entirely.
|
|
66
|
+
* * `slides`: generate a step-by-step slideshow report (requires `on=step`). Default: false.
|
|
67
|
+
* * `deleteSuccessful`: when `slides=true`, drop slideshow directories of passing tests. Default: true.
|
|
68
|
+
* * `animateSlides`: when `slides=true`, animate transitions between slides. Default: true.
|
|
69
|
+
* * `ignoreSteps`: when `slides=true`, RegExps of step names to skip in the slideshow.
|
|
70
|
+
*
|
|
71
|
+
* CLI examples:
|
|
72
|
+
*
|
|
73
|
+
* ```
|
|
74
|
+
* npx codeceptjs run -p screenshot
|
|
75
|
+
* npx codeceptjs run -p screenshot:on=step
|
|
76
|
+
* npx codeceptjs run -p screenshot:on=step;slides=true
|
|
77
|
+
* npx codeceptjs run -p screenshot:on=file:path=tests/login_test.js
|
|
78
|
+
* npx codeceptjs run -p screenshot:on=url:pattern=/users/*
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export default function (config = {}) {
|
|
82
|
+
const helper = getBrowserHelper()
|
|
83
|
+
if (!helper) return
|
|
84
|
+
|
|
85
|
+
const cliArgs = parsePluginArgs(config._args)
|
|
86
|
+
const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, { name: 'screenshot' })
|
|
87
|
+
if (!trigger) return
|
|
88
|
+
|
|
89
|
+
const helpers = Container.helpers()
|
|
90
|
+
const options = Object.assign({}, defaultConfig, helper.options, config)
|
|
91
|
+
options.slides = cliArgs.slides ?? config.slides ?? defaultConfig.slides
|
|
92
|
+
|
|
93
|
+
if (helpers.Mochawesome?.config) {
|
|
94
|
+
options.uniqueScreenshotNames = helpers.Mochawesome.config.uniqueScreenshotNames
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Codeceptjs.container.mocha()) {
|
|
98
|
+
options.reportDir = Codeceptjs.container.mocha()?.options?.reporterOptions
|
|
99
|
+
&& Codeceptjs.container.mocha()?.options?.reporterOptions?.reportDir
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (options.disableScreenshots) return
|
|
103
|
+
|
|
104
|
+
if (options.slides) {
|
|
105
|
+
return wireSlides(options, trigger)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
switch (trigger.on) {
|
|
109
|
+
case 'fail':
|
|
110
|
+
return wireOnFail(options)
|
|
111
|
+
case 'test':
|
|
112
|
+
return wireOnTest(options)
|
|
113
|
+
case 'step':
|
|
114
|
+
return wireOnStep(options, () => true)
|
|
115
|
+
case 'file':
|
|
116
|
+
return wireOnStep(options, step => matchStepFile(step, trigger.path, trigger.line))
|
|
117
|
+
case 'url':
|
|
118
|
+
return wireOnUrl(options, trigger.pattern)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function wireOnFail(options) {
|
|
123
|
+
let currentTest = null
|
|
124
|
+
event.dispatcher.on(event.test.before, test => {
|
|
125
|
+
currentTest = test
|
|
126
|
+
})
|
|
127
|
+
event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
|
|
128
|
+
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return
|
|
129
|
+
const t = test || currentTest
|
|
130
|
+
if (!t) return
|
|
131
|
+
scheduleScreenshot(t, suffix(t, options, 'failed'), options)
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function wireOnTest(options) {
|
|
136
|
+
event.dispatcher.on(event.test.after, test => {
|
|
137
|
+
if (!test) return
|
|
138
|
+
scheduleScreenshot(test, suffix(test, options, 'test'), options)
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function wireOnStep(options, filter) {
|
|
143
|
+
let currentTest = null
|
|
144
|
+
let stepCount = 0
|
|
145
|
+
event.dispatcher.on(event.test.before, test => {
|
|
146
|
+
currentTest = test
|
|
147
|
+
stepCount = 0
|
|
148
|
+
})
|
|
149
|
+
event.dispatcher.on(event.step.after, step => {
|
|
150
|
+
if (!currentTest) return
|
|
151
|
+
if (!filter(step)) return
|
|
152
|
+
stepCount++
|
|
153
|
+
const name = `${testToFileName(currentTest, { suffix: '', unique: options.uniqueScreenshotNames })}.step_${stepCount}.png`
|
|
154
|
+
scheduleScreenshot(currentTest, name, options)
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function wireOnUrl(options, pattern) {
|
|
159
|
+
let currentTest = null
|
|
160
|
+
let stepCount = 0
|
|
161
|
+
event.dispatcher.on(event.test.before, test => {
|
|
162
|
+
currentTest = test
|
|
163
|
+
stepCount = 0
|
|
164
|
+
})
|
|
165
|
+
event.dispatcher.on(event.step.after, () => {
|
|
166
|
+
if (!currentTest) return
|
|
167
|
+
const helper = getBrowserHelper()
|
|
168
|
+
if (!helper) return
|
|
169
|
+
recorder.add('screenshot:url check', async () => {
|
|
170
|
+
try {
|
|
171
|
+
const url = await helper.grabCurrentUrl()
|
|
172
|
+
if (!matchUrl(url, pattern)) return
|
|
173
|
+
stepCount++
|
|
174
|
+
const name = `${testToFileName(currentTest, { suffix: '', unique: options.uniqueScreenshotNames })}.url_${stepCount}.png`
|
|
175
|
+
await takeScreenshot(currentTest, name, options)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// page may not be ready
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function suffix(test, options, kind) {
|
|
184
|
+
const base = testToFileName(test, { suffix: '', unique: options.uniqueScreenshotNames })
|
|
185
|
+
return `${base}.${kind}.png`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function scheduleScreenshot(test, fileName, options) {
|
|
189
|
+
recorder.add(
|
|
190
|
+
'screenshot capture',
|
|
191
|
+
async () => takeScreenshot(test, fileName, options),
|
|
192
|
+
true,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function takeScreenshot(test, fileName, options) {
|
|
197
|
+
const quietMode = !store.outputDir
|
|
198
|
+
if (!quietMode) {
|
|
199
|
+
output.plugin('screenshot', `Saving screenshot ${fileName}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const helper = getBrowserHelper()
|
|
203
|
+
if (!helper || typeof helper.saveScreenshot !== 'function') return
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
if (options.reportDir) {
|
|
207
|
+
fileName = path.join(options.reportDir, fileName)
|
|
208
|
+
const mochaReportDir = path.resolve(process.cwd(), options.reportDir)
|
|
209
|
+
if (!fileExists(mochaReportDir)) fs.mkdirSync(mochaReportDir)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (helper.page && helper.page.isClosed && helper.page.isClosed()) {
|
|
213
|
+
throw new Error('Browser page has been closed')
|
|
214
|
+
}
|
|
215
|
+
if (helper.browser && helper.browser.isConnected && !helper.browser.isConnected()) {
|
|
216
|
+
throw new Error('Browser has been disconnected')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const screenshotPromise = helper.saveScreenshot(fileName, options.fullPageScreenshots)
|
|
220
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
221
|
+
setTimeout(() => reject(new Error('Screenshot timeout after 5 seconds')), 5000)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
await Promise.race([screenshotPromise, timeoutPromise])
|
|
225
|
+
|
|
226
|
+
if (!test.artifacts) test.artifacts = {}
|
|
227
|
+
const baseOutputDir = store.outputDir || null
|
|
228
|
+
if (baseOutputDir) {
|
|
229
|
+
test.artifacts.screenshot = path.join(baseOutputDir, fileName)
|
|
230
|
+
const mocha = Container.mocha()
|
|
231
|
+
const junit = mocha?.options?.reporterOptions?.['mocha-junit-reporter']
|
|
232
|
+
if (junit?.options?.attachments) {
|
|
233
|
+
test.attachments = [path.join(baseOutputDir, fileName)]
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
test.artifacts.screenshot = fileName
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (!quietMode) {
|
|
240
|
+
output.plugin('screenshot', `Failed to save screenshot: ${err.message}`)
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
err
|
|
244
|
+
&& ((err.message
|
|
245
|
+
&& (err.message.includes('Target page, context or browser has been closed')
|
|
246
|
+
|| err.message.includes('Browser page has been closed')
|
|
247
|
+
|| err.message.includes('Browser has been disconnected')
|
|
248
|
+
|| err.message.includes('was terminated due to')
|
|
249
|
+
|| err.message.includes('no such window: target window already closed')
|
|
250
|
+
|| err.message.includes('Screenshot timeout after')))
|
|
251
|
+
|| (err.type && err.type === 'RuntimeError'))
|
|
252
|
+
) {
|
|
253
|
+
output.log(`Can't make screenshot, ${err.message}`)
|
|
254
|
+
helper.isRunning = false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function wireSlides(options, trigger) {
|
|
260
|
+
const reportDir = options.output
|
|
261
|
+
? path.resolve(store.codeceptDir, options.output)
|
|
262
|
+
: (store.outputDir || './_output')
|
|
263
|
+
|
|
264
|
+
const stepFilter = makeStepFilter(trigger, options)
|
|
265
|
+
const recordedTests = {}
|
|
266
|
+
|
|
267
|
+
let dir
|
|
268
|
+
let stepNum
|
|
269
|
+
let slides = {}
|
|
270
|
+
let savedStep = null
|
|
271
|
+
let currentTest = null
|
|
272
|
+
let scenarioFailed = false
|
|
273
|
+
|
|
274
|
+
event.dispatcher.on(event.suite.before, () => {
|
|
275
|
+
stepNum = -1
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
event.dispatcher.on(event.test.before, test => {
|
|
279
|
+
const hash = crypto.createHash('sha256').update(test.file + test.title).digest('hex')
|
|
280
|
+
dir = path.join(reportDir, `record_${hash}`)
|
|
281
|
+
mkdirp.sync(dir)
|
|
282
|
+
stepNum = 0
|
|
283
|
+
slides = {}
|
|
284
|
+
savedStep = null
|
|
285
|
+
currentTest = test
|
|
286
|
+
scenarioFailed = false
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
event.dispatcher.on(event.step.failed, step => {
|
|
290
|
+
recorder.add('slides: failed step', async () => persistStep(step), true)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
event.dispatcher.on(event.step.after, step => {
|
|
294
|
+
recorder.add('slides: step', async () => persistStep(step), true)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
event.dispatcher.on(event.test.passed, test => {
|
|
298
|
+
if (options.deleteSuccessful) {
|
|
299
|
+
deleteDir(dir)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
persist(test)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
|
|
306
|
+
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return
|
|
307
|
+
persist(test)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
event.dispatcher.on(event.all.result, () => {
|
|
311
|
+
if (Object.keys(recordedTests).length === 0) return
|
|
312
|
+
writeIndex(reportDir, recordedTests)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
if (event.workers && event.workers.result) {
|
|
316
|
+
event.dispatcher.on(event.workers.result, async () => {
|
|
317
|
+
await recorder.add(() => {
|
|
318
|
+
const tests = scanRecordDirs(reportDir)
|
|
319
|
+
if (Object.keys(tests).length) writeIndex(reportDir, tests)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function persistStep(step) {
|
|
325
|
+
if (stepNum === -1) return
|
|
326
|
+
if (savedStep === step) return
|
|
327
|
+
if (scenarioFailed) return
|
|
328
|
+
if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
|
|
329
|
+
if (!currentTest) return
|
|
330
|
+
if (!stepFilter(step)) return
|
|
331
|
+
if (isStepIgnored(step, options.ignoreSteps)) return
|
|
332
|
+
|
|
333
|
+
const fileName = `${String(stepNum).padStart(4, '0')}.png`
|
|
334
|
+
if (step.status === 'failed') scenarioFailed = true
|
|
335
|
+
stepNum++
|
|
336
|
+
slides[fileName] = step
|
|
337
|
+
|
|
338
|
+
const helper = getBrowserHelper()
|
|
339
|
+
if (!helper || typeof helper.saveScreenshot !== 'function') return
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const screenshotPath = path.join(dir, fileName)
|
|
343
|
+
await helper.saveScreenshot(screenshotPath, options.fullPageScreenshots)
|
|
344
|
+
step.artifacts = step.artifacts || {}
|
|
345
|
+
step.artifacts.screenshot = screenshotPath
|
|
346
|
+
|
|
347
|
+
currentTest.artifacts = currentTest.artifacts || {}
|
|
348
|
+
currentTest.artifacts.screenshots = currentTest.artifacts.screenshots || []
|
|
349
|
+
currentTest.artifacts.screenshots.push(screenshotPath)
|
|
350
|
+
} catch (err) {
|
|
351
|
+
output.plugin('screenshot', `Can't save step screenshot: ${err.message}`)
|
|
352
|
+
} finally {
|
|
353
|
+
savedStep = step
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function persist(test) {
|
|
358
|
+
if (!Object.keys(slides).length) return
|
|
359
|
+
|
|
360
|
+
const slideHtml = Object.keys(slides)
|
|
361
|
+
.sort()
|
|
362
|
+
.map((fileName, idx) => {
|
|
363
|
+
const step = slides[fileName]
|
|
364
|
+
const caption = step.toString().replace(/\[\d{2}m/g, '')
|
|
365
|
+
const failed = step.status === 'failed' ? ' is-failed' : ''
|
|
366
|
+
return template(SLIDE_TEMPLATE, {
|
|
367
|
+
image: fileName,
|
|
368
|
+
caption,
|
|
369
|
+
index: idx + 1,
|
|
370
|
+
activeClass: idx === 0 ? ' is-active' : '',
|
|
371
|
+
failed,
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
.join('')
|
|
375
|
+
|
|
376
|
+
const dotHtml = Object.keys(slides)
|
|
377
|
+
.map((_, idx) => `<button type="button" class="slides__dot${idx === 0 ? ' is-active' : ''}" data-slide="${idx}" aria-label="Step ${idx + 1}"></button>`)
|
|
378
|
+
.join('')
|
|
379
|
+
|
|
380
|
+
const html = template(SLIDESHOW_TEMPLATE, {
|
|
381
|
+
title: test.title,
|
|
382
|
+
feature: (test.parent && test.parent.title) || '',
|
|
383
|
+
slides: slideHtml,
|
|
384
|
+
dots: dotHtml,
|
|
385
|
+
animate: options.animateSlides ? 'true' : 'false',
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
const indexFile = path.join(dir, 'index.html')
|
|
389
|
+
fs.writeFileSync(indexFile, html)
|
|
390
|
+
recordedTests[`${(test.parent && test.parent.title) || ''}: ${test.title}`] = path.relative(reportDir, indexFile)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function makeStepFilter(trigger, options) {
|
|
395
|
+
if (trigger.on === 'file' && trigger.path) {
|
|
396
|
+
return step => matchStepFile(step, trigger.path, trigger.line)
|
|
397
|
+
}
|
|
398
|
+
if (trigger.on === 'fail') {
|
|
399
|
+
return step => step.status === 'failed'
|
|
400
|
+
}
|
|
401
|
+
return () => true
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function isStepIgnored(step, patterns) {
|
|
405
|
+
if (!patterns || !patterns.length) return false
|
|
406
|
+
for (const pattern of patterns) {
|
|
407
|
+
if (step.name && step.name.match(pattern)) return true
|
|
408
|
+
}
|
|
409
|
+
return false
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function scanRecordDirs(reportDir) {
|
|
413
|
+
const out = {}
|
|
414
|
+
try {
|
|
415
|
+
for (const item of fs.readdirSync(reportDir, { withFileTypes: true })) {
|
|
416
|
+
if (!item.isDirectory() || !item.name.startsWith('record_')) continue
|
|
417
|
+
const indexFile = path.join(reportDir, item.name, 'index.html')
|
|
418
|
+
if (!fs.existsSync(indexFile)) continue
|
|
419
|
+
const html = fs.readFileSync(indexFile, 'utf-8')
|
|
420
|
+
const titleMatch = html.match(/<title>([^<]*)<\/title>/)
|
|
421
|
+
const label = titleMatch ? titleMatch[1].replace(/^Slides — /, '') : item.name
|
|
422
|
+
out[label] = `${item.name}/index.html`
|
|
423
|
+
}
|
|
424
|
+
} catch (err) {
|
|
425
|
+
// ignore
|
|
426
|
+
}
|
|
427
|
+
return out
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function writeIndex(reportDir, recordedTests) {
|
|
431
|
+
const items = Object.entries(recordedTests)
|
|
432
|
+
.map(([name, href]) => `<li><a href="${href}">${escapeHtml(name)}</a></li>`)
|
|
433
|
+
.join('\n')
|
|
434
|
+
|
|
435
|
+
const html = template(INDEX_TEMPLATE, {
|
|
436
|
+
time: new Date().toString(),
|
|
437
|
+
records: items,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
const indexPath = path.join(reportDir, 'records.html')
|
|
441
|
+
fs.writeFileSync(indexPath, html)
|
|
442
|
+
output.print(`Step-by-step preview: file://${indexPath}`)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function escapeHtml(s) {
|
|
446
|
+
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const SLIDE_TEMPLATE = `
|
|
450
|
+
<figure class="slides__slide{{activeClass}}{{failed}}" data-index="{{index}}">
|
|
451
|
+
<img class="slides__image" src="{{image}}" alt="">
|
|
452
|
+
<figcaption class="slides__caption">
|
|
453
|
+
<span class="slides__step">{{index}}</span>
|
|
454
|
+
<span class="slides__text">{{caption}}</span>
|
|
455
|
+
</figcaption>
|
|
456
|
+
</figure>
|
|
457
|
+
`
|
|
458
|
+
|
|
459
|
+
const SLIDESHOW_TEMPLATE = `<!DOCTYPE html>
|
|
460
|
+
<html lang="en">
|
|
461
|
+
<head>
|
|
462
|
+
<meta charset="utf-8">
|
|
463
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
464
|
+
<title>Slides — {{feature}}: {{title}}</title>
|
|
465
|
+
<style>
|
|
466
|
+
:root { color-scheme: dark; --bg: #0b0d10; --panel: #14181d; --fg: #e7ecef; --muted: #8a96a0; --accent: #ff5b00; --error: #c0392b; }
|
|
467
|
+
* { box-sizing: border-box; }
|
|
468
|
+
html, body { height: 100%; margin: 0; }
|
|
469
|
+
body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Inter, sans-serif; display: flex; flex-direction: column; }
|
|
470
|
+
header { padding: 14px 20px; background: var(--panel); border-bottom: 1px solid #1f262d; display: flex; align-items: baseline; gap: 16px; }
|
|
471
|
+
header a { color: var(--muted); text-decoration: none; font-weight: 500; }
|
|
472
|
+
header a:hover { color: var(--fg); }
|
|
473
|
+
header .feature { color: var(--muted); }
|
|
474
|
+
header .test { font-weight: 600; }
|
|
475
|
+
.slides { flex: 1; position: relative; overflow: hidden; }
|
|
476
|
+
.slides__slide { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity .25s ease; }
|
|
477
|
+
.slides[data-animate="false"] .slides__slide { transition: none; }
|
|
478
|
+
.slides__slide.is-active { opacity: 1; pointer-events: auto; }
|
|
479
|
+
.slides__image { max-width: 100%; max-height: 100%; object-fit: contain; box-shadow: 0 10px 40px rgba(0,0,0,.4); }
|
|
480
|
+
.slides__caption { position: absolute; left: 20px; right: 20px; bottom: 24px; padding: 12px 16px; background: rgba(20,24,29,.92); border: 1px solid #1f262d; border-radius: 6px; display: flex; gap: 12px; align-items: baseline; }
|
|
481
|
+
.slides__slide.is-failed .slides__caption { background: var(--error); border-color: var(--error); }
|
|
482
|
+
.slides__step { font-variant-numeric: tabular-nums; color: var(--muted); font-weight: 600; min-width: 2ch; }
|
|
483
|
+
.slides__slide.is-failed .slides__step { color: #ffd9d4; }
|
|
484
|
+
.slides__text { word-break: break-word; }
|
|
485
|
+
.nav { position: absolute; top: 0; bottom: 0; width: 25%; background: transparent; border: 0; cursor: pointer; color: transparent; }
|
|
486
|
+
.nav--prev { left: 0; }
|
|
487
|
+
.nav--next { right: 0; }
|
|
488
|
+
.dots { display: flex; gap: 6px; justify-content: center; padding: 12px; background: var(--panel); border-top: 1px solid #1f262d; flex-wrap: wrap; }
|
|
489
|
+
.slides__dot { width: 10px; height: 10px; border-radius: 50%; border: 0; background: #2a323a; cursor: pointer; padding: 0; }
|
|
490
|
+
.slides__dot.is-active { background: var(--accent); }
|
|
491
|
+
.slides__dot:hover { background: #3d4751; }
|
|
492
|
+
.slides__dot.is-active:hover { background: var(--accent); }
|
|
493
|
+
.hint { color: var(--muted); font-size: 12px; padding: 8px 20px; text-align: center; background: var(--panel); border-top: 1px solid #1f262d; }
|
|
494
|
+
</style>
|
|
495
|
+
</head>
|
|
496
|
+
<body>
|
|
497
|
+
<header>
|
|
498
|
+
<a href="../records.html">« back</a>
|
|
499
|
+
<span class="feature">{{feature}}</span>
|
|
500
|
+
<span class="test">{{title}}</span>
|
|
501
|
+
</header>
|
|
502
|
+
<div class="slides" data-animate="{{animate}}">
|
|
503
|
+
{{slides}}
|
|
504
|
+
<button class="nav nav--prev" type="button" aria-label="Previous">←</button>
|
|
505
|
+
<button class="nav nav--next" type="button" aria-label="Next">→</button>
|
|
506
|
+
</div>
|
|
507
|
+
<nav class="dots">{{dots}}</nav>
|
|
508
|
+
<p class="hint">Use ← / → to navigate, click sides of image, or use the dots below.</p>
|
|
509
|
+
<script>
|
|
510
|
+
(function () {
|
|
511
|
+
var slidesEl = document.querySelector('.slides');
|
|
512
|
+
var slides = Array.prototype.slice.call(slidesEl.querySelectorAll('.slides__slide'));
|
|
513
|
+
var dots = Array.prototype.slice.call(document.querySelectorAll('.slides__dot'));
|
|
514
|
+
var idx = 0;
|
|
515
|
+
function show(i) {
|
|
516
|
+
if (i < 0) i = slides.length - 1;
|
|
517
|
+
if (i >= slides.length) i = 0;
|
|
518
|
+
slides[idx].classList.remove('is-active');
|
|
519
|
+
dots[idx] && dots[idx].classList.remove('is-active');
|
|
520
|
+
idx = i;
|
|
521
|
+
slides[idx].classList.add('is-active');
|
|
522
|
+
dots[idx] && dots[idx].classList.add('is-active');
|
|
523
|
+
}
|
|
524
|
+
document.querySelector('.nav--prev').addEventListener('click', function () { show(idx - 1); });
|
|
525
|
+
document.querySelector('.nav--next').addEventListener('click', function () { show(idx + 1); });
|
|
526
|
+
dots.forEach(function (d, i) { d.addEventListener('click', function () { show(i); }); });
|
|
527
|
+
document.addEventListener('keydown', function (e) {
|
|
528
|
+
if (e.key === 'ArrowLeft') show(idx - 1);
|
|
529
|
+
if (e.key === 'ArrowRight') show(idx + 1);
|
|
530
|
+
});
|
|
531
|
+
})();
|
|
532
|
+
</script>
|
|
533
|
+
</body>
|
|
534
|
+
</html>
|
|
535
|
+
`
|
|
536
|
+
|
|
537
|
+
const INDEX_TEMPLATE = `<!DOCTYPE html>
|
|
538
|
+
<html lang="en">
|
|
539
|
+
<head>
|
|
540
|
+
<meta charset="utf-8">
|
|
541
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
542
|
+
<title>Step-by-step Reports</title>
|
|
543
|
+
<style>
|
|
544
|
+
:root { color-scheme: dark; --bg: #0b0d10; --panel: #14181d; --fg: #e7ecef; --muted: #8a96a0; --accent: #ff5b00; }
|
|
545
|
+
* { box-sizing: border-box; }
|
|
546
|
+
body { background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Inter, sans-serif; max-width: 880px; margin: 0 auto; padding: 32px 24px; }
|
|
547
|
+
h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; }
|
|
548
|
+
.meta { color: var(--muted); margin-bottom: 24px; font-size: 13px; }
|
|
549
|
+
ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 4px; }
|
|
550
|
+
li { background: var(--panel); border: 1px solid #1f262d; border-radius: 6px; }
|
|
551
|
+
li a { display: block; padding: 12px 16px; color: var(--fg); text-decoration: none; }
|
|
552
|
+
li a:hover { background: #1c2229; border-color: var(--accent); }
|
|
553
|
+
</style>
|
|
554
|
+
</head>
|
|
555
|
+
<body>
|
|
556
|
+
<h1>Step-by-step Reports</h1>
|
|
557
|
+
<div class="meta">{{time}}</div>
|
|
558
|
+
<ul>
|
|
559
|
+
{{records}}
|
|
560
|
+
</ul>
|
|
561
|
+
</body>
|
|
562
|
+
</html>
|
|
563
|
+
`
|