doc-detective 3.0.2 → 3.0.4
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 +16 -14
- package/package.json +3 -3
- package/samples/.doc-detective.json +75 -64
- package/samples/doc-content-detect.md +2 -3
- package/samples/doc-content-inline-tests.md +12 -17
- package/samples/docker-hello.spec.json +4 -3
- package/samples/env +3 -0
- package/samples/http.spec.yaml +37 -0
- package/samples/kitten-search-detect.md +8 -0
- package/samples/local-gui.md +6 -0
- package/src/index.js +4 -7
- package/src/utils.js +156 -65
- package/test/utils.test.js +63 -15
- package/samples/http.spec.json +0 -26
package/README.md
CHANGED
|
@@ -69,21 +69,23 @@ You can find test and config samples in the [samples](https://github.com/doc-det
|
|
|
69
69
|
|
|
70
70
|
## Concepts
|
|
71
71
|
|
|
72
|
-
-
|
|
73
|
-
- [**Test**](https://doc-detective.com/docs/
|
|
72
|
+
- **Test specification**: A group of tests to run in one or more contexts. Conceptually parallel to a document.
|
|
73
|
+
- [**Test**](https://doc-detective.com/docs/get-started/tests): A sequence of steps to perform. Conceptually parallel to a procedure.
|
|
74
74
|
- **Step**: A portion of a test that includes a single action. Conceptually parallel to a step in a procedure.
|
|
75
75
|
- **Action**: The task a performed in a step. Doc Detective supports a variety of actions:
|
|
76
|
-
- [**checkLink**](https://doc-detective.com/docs/
|
|
77
|
-
- [**
|
|
78
|
-
- [**
|
|
79
|
-
- [**
|
|
80
|
-
- [**
|
|
81
|
-
- [**
|
|
82
|
-
- [**
|
|
83
|
-
- [**
|
|
84
|
-
- [**
|
|
85
|
-
- [**
|
|
86
|
-
- [**
|
|
76
|
+
- [**checkLink**](https://doc-detective.com/docs/get-started/actions/checkLink): Check if a URL returns an acceptable status code from a GET request.
|
|
77
|
+
- [**click**](https://doc-detective.com/docs/get-started/actions/click): Click an element with the specified text or selector.
|
|
78
|
+
- [**find**](https://doc-detective.com/docs/get-started/actions/find): Check if an element exists with the specified text or selector and optionally interact with it.
|
|
79
|
+
- [**goTo**](https://doc-detective.com/docs/get-started/actions/goTo): Navigate to a specified URL.
|
|
80
|
+
- [**httpRequest**](https://doc-detective.com/docs/get-started/actions/httpRequest): Perform a generic HTTP request, for example to an API.
|
|
81
|
+
- [**runCode**](https://doc-detective.com/docs/get-started/actions/runCode): Execute code, such as how it appears in a code block.
|
|
82
|
+
- [**runShell**](https://doc-detective.com/docs/get-started/actions/runShell): Perform a native shell command.
|
|
83
|
+
- [**screenshot**](https://doc-detective.com/docs/get-started/actions/screenshot): Take a screenshot in PNG format.
|
|
84
|
+
- [**loadVariables**](https://doc-detective.com/docs/get-started/actions/loadVariables): Load environment variables from a `.env` file.
|
|
85
|
+
- [**record**](https://doc-detective.com/docs/get-started/actions/record) and [**stopRecord**](https://doc-detective.com/docs/get-started/actions/stopRecord): Capture a video of test execution.
|
|
86
|
+
- [**type**](https://doc-detective.com/docs/get-started/actions/type): Type keys. To type special keys, begin and end the string with `$` and use the special key’s enum. For example, to type the Escape key, enter `$ESCAPE$`.
|
|
87
|
+
- [**wait**](https://doc-detective.com/docs/get-started/actions/wait): Pause before performing the next action.
|
|
88
|
+
- [**Context**](https://doc-detective.com/docs/get-started/config/contexts): A combination of platform and application to run tests on.
|
|
87
89
|
|
|
88
90
|
## Develop
|
|
89
91
|
|
|
@@ -105,4 +107,4 @@ Looking to help out? See our [contributions guide](CONTRIBUTIONS.md) for more in
|
|
|
105
107
|
|
|
106
108
|
## License
|
|
107
109
|
|
|
108
|
-
This project uses the [
|
|
110
|
+
This project uses the [AGPL-3.0 license](https://github.com/doc-detective/doc-detective/blob/master/LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doc-detective",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.4",
|
|
4
4
|
"description": "Treat doc content as testable assertions to validate doc accuracy and product UX.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"doc-detective": "src/index.js"
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"homepage": "https://github.com/doc-detective/doc-detective#readme",
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
36
|
-
"doc-detective-common": "^3.0.
|
|
37
|
-
"doc-detective-core": "^3.0.
|
|
36
|
+
"doc-detective-common": "^3.0.3",
|
|
37
|
+
"doc-detective-core": "^3.0.5",
|
|
38
38
|
"yargs": "^17.7.2"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
@@ -1,83 +1,94 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"cleanup": "",
|
|
14
|
-
"recursive": true,
|
|
15
|
-
"mediaDirectory": ".",
|
|
16
|
-
"downloadDirectory": ".",
|
|
17
|
-
"contexts": [
|
|
18
|
-
{
|
|
19
|
-
"app": {
|
|
20
|
-
"name": "chrome",
|
|
21
|
-
"options": {
|
|
22
|
-
"headless": false
|
|
23
|
-
}
|
|
24
|
-
},
|
|
25
|
-
"platforms": ["windows", "mac", "linux"]
|
|
26
|
-
}
|
|
27
|
-
]
|
|
28
|
-
},
|
|
29
|
-
"runCoverage": {
|
|
30
|
-
"recursive": true,
|
|
31
|
-
"input": ".",
|
|
32
|
-
"output": ".",
|
|
33
|
-
"markup": []
|
|
34
|
-
},
|
|
2
|
+
"runOn": [
|
|
3
|
+
{
|
|
4
|
+
"platforms": ["windows", "mac", "linux"],
|
|
5
|
+
"browsers": [
|
|
6
|
+
{
|
|
7
|
+
"name": "firefox",
|
|
8
|
+
"headless": false
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
],
|
|
35
13
|
"fileTypes": [
|
|
36
14
|
{
|
|
37
|
-
"name": "
|
|
38
|
-
"extensions": ["
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
15
|
+
"name": "markdown",
|
|
16
|
+
"extensions": ["md", "markdown", "mdx"],
|
|
17
|
+
"inlineStatements": {
|
|
18
|
+
"testStart": [
|
|
19
|
+
"{\\/\\*\\s*test\\s+?([\\s\\S]*?)\\s*\\*\\/}",
|
|
20
|
+
"<!--\\s*test\\s*([\\s\\S]*?)\\s*-->",
|
|
21
|
+
"\\[comment\\]:\\s+#\\s+\\(test\\s*(.*?)\\s*\\)",
|
|
22
|
+
"\\[comment\\]:\\s+#\\s+\\(test start\\s*(.*?)\\s*\\)"
|
|
23
|
+
],
|
|
24
|
+
"testEnd": [
|
|
25
|
+
"{\\/\\*\\s*test end\\s*\\*\\/}",
|
|
26
|
+
"<!--\\s*test end\\s*([\\s\\S]*?)\\s*-->",
|
|
27
|
+
"\\[comment\\]:\\s+#\\s+\\(test end\\)"
|
|
28
|
+
],
|
|
29
|
+
"ignoreStart": [
|
|
30
|
+
"{\\/\\*\\s*test ignore start\\s*\\*\\/}",
|
|
31
|
+
"<!--\\s*test ignore start\\s*-->"
|
|
32
|
+
],
|
|
33
|
+
"ignoreEnd": [
|
|
34
|
+
"{\\/\\*\\s*test ignore end\\s*\\*\\/}",
|
|
35
|
+
"<!--\\s*test ignore end\\s*-->"
|
|
36
|
+
],
|
|
37
|
+
"step": [
|
|
38
|
+
"{\\/\\*\\s*step\\s+?([\\s\\S]*?)\\s*\\*\\/}",
|
|
39
|
+
"<!--\\s*step\\s*([\\s\\S]*?)\\s*-->",
|
|
40
|
+
"\\[comment\\]:\\s+#\\s+\\(step\\s*(.*?)\\s*\\)"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
45
43
|
"markup": [
|
|
46
44
|
{
|
|
47
|
-
"name": "
|
|
48
|
-
"regex": [
|
|
45
|
+
"name": "checkHyperlink",
|
|
46
|
+
"regex": [
|
|
47
|
+
"(?<!\\!)\\[[^\\]]+\\]\\(\\s*(https?:\\/\\/[^\\s)]+)(?:\\s+\"[^\"]*\")?\\s*\\)"
|
|
48
|
+
],
|
|
49
49
|
"actions": ["checkLink"]
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
|
-
"name": "
|
|
52
|
+
"name": "pressEnter",
|
|
53
|
+
"regex": ["\\bpress Enter"],
|
|
54
|
+
"actions": [
|
|
55
|
+
{
|
|
56
|
+
"type": "$ENTER$"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "clickOnscreenText",
|
|
53
62
|
"regex": [
|
|
54
|
-
"(?:[Cc]
|
|
63
|
+
"\\b(?:[Cc]lick|[Tt]ap|[Ll]eft-click|[Cc]hoose|[Ss]elect|[Cc]heck)\\b\\s+\\*\\*((?:(?!\\*\\*).)+)\\*\\*"
|
|
55
64
|
],
|
|
56
|
-
"actions": ["
|
|
65
|
+
"actions": ["click"]
|
|
57
66
|
},
|
|
58
67
|
{
|
|
59
|
-
"name": "
|
|
60
|
-
"regex": ["\\*\\*(
|
|
68
|
+
"name": "findOnscreenText",
|
|
69
|
+
"regex": ["\\*\\*((?:(?!\\*\\*).)+)\\*\\*"],
|
|
61
70
|
"actions": ["find"]
|
|
62
71
|
},
|
|
63
72
|
{
|
|
64
|
-
"name": "
|
|
65
|
-
"regex": [
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
"name": "goToUrl",
|
|
74
|
+
"regex": [
|
|
75
|
+
"\\b(?:[Gg]o\\s+to|[Oo]pen|[Nn]avigate\\s+to|[Vv]isit|[Aa]ccess|[Pp]roceed\\s+to|[Ll]aunch)\\b\\s+\\[[^\\]]+\\]\\(\\s*(https?:\\/\\/[^\\s)]+)(?:\\s+\"[^\"]*\")?\\s*\\)"
|
|
76
|
+
],
|
|
77
|
+
"actions": ["goTo"]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "screenshotImage",
|
|
81
|
+
"regex": [
|
|
82
|
+
"!\\[[^\\]]*\\]\\(\\s*([^\\s)]+)(?:\\s+\"[^\"]*\")?\\s*\\)\\s*\\{(?=[^}]*\\.screenshot)[^}]*\\}"
|
|
83
|
+
],
|
|
84
|
+
"actions": ["screenshot"]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"name": "typeText",
|
|
88
|
+
"regex": ["\\b(?:press|enter|type)\\b\\s+\"([^\"]+)\""],
|
|
89
|
+
"actions": ["type"]
|
|
74
90
|
}
|
|
75
91
|
]
|
|
76
92
|
}
|
|
77
|
-
]
|
|
78
|
-
"integrations": {},
|
|
79
|
-
"telemetry": {
|
|
80
|
-
"send": true,
|
|
81
|
-
"userId": "Doc Detective Samples"
|
|
82
|
-
}
|
|
93
|
+
]
|
|
83
94
|
}
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
[Doc Detective documentation](https://doc-detective.com) is split into a few key sections:
|
|
4
4
|
|
|
5
5
|
- The landing page discusses what Doc Detective is, what it does, and who might find it useful.
|
|
6
|
-
|
|
7
6
|
- [Get started](https://doc-detective.com/docs/get-started/intro) covers how to quickly get up and running with Doc Detective.
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
Some pages also have unique headings. If you open [type](https://doc-detective.com/docs/get-started/actions/type), it has a **Special keys** heading.
|
|
10
9
|
|
|
11
|
-

|
|
10
|
+
{ .screenshot }
|
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
# Doc Detective documentation overview
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- test
|
|
4
|
+
testId: doc-detective-docs
|
|
5
|
+
detectSteps: false
|
|
6
|
+
-->
|
|
4
7
|
|
|
5
|
-
[Doc Detective documentation](
|
|
8
|
+
[Doc Detective documentation](https://doc-detective.com) is split into a few key sections:
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
<!-- step checkLink: "https://doc-detective.com" -->
|
|
8
11
|
|
|
9
12
|
- The landing page discusses what Doc Detective is, what it does, and who might find it useful.
|
|
10
|
-
|
|
11
13
|
- [Get started](https://doc-detective.com/docs/get-started/intro) covers how to quickly get up and running with Doc Detective.
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- The [references](https://doc-detective.com/docs/category/schemas) detail the various JSON objects that Doc Detective expects for [configs](https://doc-detective.com/docs/references/schemas/config.html), [test specifications](https://doc-detective.com/docs/references/schemas/specification.html), [tests](https://doc-detective.com/docs/references/schemas/test), actions, and more. Open [typeKeys](https://doc-detective.com/docs/references/schemas/typeKeys.html)--or any other schema--and you'll find two sections: **Fields** and **Examples**.
|
|
15
|
+
<!-- step checkLink: "https://doc-detective.com/docs/get-started/intro" -->
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
[comment]: # (step {"action":"checkLink", "url":"https://doc-detective.com/docs/references/schemas/config.html"})
|
|
19
|
-
[comment]: # (step {"action":"checkLink", "url":"https://doc-detective.com/docs/references/schemas/specification.html"})
|
|
20
|
-
[comment]: # (step {"action":"checkLink", "url":"https://doc-detective.com/docs/references/schemas/test.html"})
|
|
21
|
-
[comment]: # (step {"action":"goTo", "url":"https://doc-detective.com/docs/references/schemas/typeKeys.html"})
|
|
22
|
-
[comment]: # (step {"action":"find", "selector":"h2#fields", "matchText":"Fields"})
|
|
23
|
-
[comment]: # (step {"action":"find", "selector":"h2#examples", "matchText":"Examples"})
|
|
17
|
+
Some pages also have unique headings. If you open [type](https://doc-detective.com/docs/get-started/actions/type) it has **Special keys**.
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
<!-- step goTo: "https://doc-detective.com/docs/get-started/actions/type" -->
|
|
20
|
+
<!-- step find: Special keys -->
|
|
26
21
|
|
|
27
|
-
[
|
|
28
|
-
|
|
22
|
+
{ .screenshot }
|
|
23
|
+
<!-- step screenshot: reference.png -->
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
{
|
|
4
4
|
"steps": [
|
|
5
5
|
{
|
|
6
|
-
"action": "runShell",
|
|
7
6
|
"description": "Run a Docker container and check the output.",
|
|
8
|
-
"
|
|
9
|
-
|
|
7
|
+
"runShell": {
|
|
8
|
+
"command": "docker run hello-world",
|
|
9
|
+
"stdio": "Hello from Docker!"
|
|
10
|
+
}
|
|
10
11
|
}
|
|
11
12
|
]
|
|
12
13
|
}
|
package/samples/env
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
tests:
|
|
2
|
+
- steps:
|
|
3
|
+
- loadVariables: env
|
|
4
|
+
- httpRequest:
|
|
5
|
+
url: http://localhost:8080/api/users
|
|
6
|
+
method: post
|
|
7
|
+
request:
|
|
8
|
+
body:
|
|
9
|
+
name: $USER
|
|
10
|
+
job: $JOB
|
|
11
|
+
response:
|
|
12
|
+
body:
|
|
13
|
+
name: John Doe
|
|
14
|
+
job: Software Engineer
|
|
15
|
+
- httpRequest:
|
|
16
|
+
url: http://localhost:8080/api/users
|
|
17
|
+
method: post
|
|
18
|
+
request:
|
|
19
|
+
body:
|
|
20
|
+
data:
|
|
21
|
+
- first_name: George
|
|
22
|
+
last_name: Bluth
|
|
23
|
+
id: 1
|
|
24
|
+
response:
|
|
25
|
+
body:
|
|
26
|
+
data:
|
|
27
|
+
- first_name: George
|
|
28
|
+
last_name: Bluth
|
|
29
|
+
variables:
|
|
30
|
+
ID: $$response.body.data[0].id
|
|
31
|
+
- httpRequest:
|
|
32
|
+
url: http://localhost:8080/api/$ID
|
|
33
|
+
method: get
|
|
34
|
+
timeout: 1000
|
|
35
|
+
savePath: response.json
|
|
36
|
+
maxVariation: 0
|
|
37
|
+
overwrite: aboveVariation
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
To search for American Shorthair kittens,
|
|
2
|
+
|
|
3
|
+
1. Go to [DuckDuckGo](https://www.duckduckgo.com).
|
|
4
|
+
2. In the search bar, enter "American Shorthair kittens", then press Enter.
|
|
5
|
+
|
|
6
|
+
<!-- step wait: 10000 -->
|
|
7
|
+
|
|
8
|
+
{ .screenshot }
|
package/src/index.js
CHANGED
|
@@ -19,7 +19,8 @@ async function main(argv) {
|
|
|
19
19
|
);
|
|
20
20
|
// Set args
|
|
21
21
|
argv = setArgs(argv);
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
// Get .doc-detective JSON or YAML config, if it exists, preferring a config arg if provided
|
|
23
24
|
const configPathJSON = path.resolve(process.cwd(), ".doc-detective.json");
|
|
24
25
|
const configPathYAML = path.resolve(process.cwd(), ".doc-detective.yaml");
|
|
25
26
|
const configPathYML = path.resolve(process.cwd(), ".doc-detective.yml");
|
|
@@ -32,13 +33,9 @@ async function main(argv) {
|
|
|
32
33
|
: fs.existsSync(configPathYML)
|
|
33
34
|
? configPathYML
|
|
34
35
|
: null;
|
|
35
|
-
|
|
36
|
-
let config = {};
|
|
37
|
-
if (configPath) {
|
|
38
|
-
config = await readFile({ fileURLOrPath: configPath });
|
|
39
|
-
}
|
|
36
|
+
|
|
40
37
|
// Set config
|
|
41
|
-
config = await setConfig(
|
|
38
|
+
config = await setConfig({ configPath: configPath, args: argv });
|
|
42
39
|
|
|
43
40
|
// Run tests
|
|
44
41
|
const output = config.output;
|
package/src/utils.js
CHANGED
|
@@ -46,7 +46,22 @@ function setArgs(args) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Override config values based on args and validate the config
|
|
49
|
-
async function setConfig(
|
|
49
|
+
async function setConfig({ configPath, args }) {
|
|
50
|
+
if (args.config && !configPath) {
|
|
51
|
+
configPath = args.config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// If config file exists, read it
|
|
55
|
+
let config = {};
|
|
56
|
+
if (configPath) {
|
|
57
|
+
try {
|
|
58
|
+
config = await readFile({ fileURLOrPath: configPath });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(`Error reading config file at ${configPath}: ${error}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
// Validate config
|
|
51
66
|
const validation = validate({
|
|
52
67
|
schemaKey: "config_v3",
|
|
@@ -72,12 +87,22 @@ async function setConfig(config, args, configPath) {
|
|
|
72
87
|
loadVariables: config.loadVariables || ".env",
|
|
73
88
|
detectSteps: config.detectSteps || true,
|
|
74
89
|
logLevel: config.logLevel || "info",
|
|
75
|
-
fileTypes: config.fileTypes || ["markdown","asciidoc", "html"],
|
|
90
|
+
fileTypes: config.fileTypes || ["markdown", "asciidoc", "html"],
|
|
76
91
|
telemetry: config.telemetry || { send: true },
|
|
77
|
-
}
|
|
92
|
+
};
|
|
78
93
|
// Override config values
|
|
79
94
|
if (args.input) {
|
|
80
|
-
|
|
95
|
+
// If input includes commas, split it into an array
|
|
96
|
+
args.input = args.input.split(",").map((item) => item.trim());
|
|
97
|
+
// Resolve paths
|
|
98
|
+
args.input = args.input.map((item) => {
|
|
99
|
+
if (item.startsWith("https://") || item.startsWith("http://")) {
|
|
100
|
+
return item; // Don't resolve URLs
|
|
101
|
+
}
|
|
102
|
+
return path.resolve(item);
|
|
103
|
+
});
|
|
104
|
+
// Add to config
|
|
105
|
+
config.input = args.input;
|
|
81
106
|
}
|
|
82
107
|
if (args.output) {
|
|
83
108
|
config.output = path.resolve(args.output);
|
|
@@ -89,7 +114,7 @@ async function setConfig(config, args, configPath) {
|
|
|
89
114
|
config = await resolvePaths({
|
|
90
115
|
config: config,
|
|
91
116
|
object: config,
|
|
92
|
-
filePath: configPath,
|
|
117
|
+
filePath: configPath || ".",
|
|
93
118
|
nested: false,
|
|
94
119
|
objectType: "config",
|
|
95
120
|
});
|
|
@@ -161,7 +186,7 @@ const reporters = {
|
|
|
161
186
|
yellow: "\x1b[33m",
|
|
162
187
|
cyan: "\x1b[36m",
|
|
163
188
|
reset: "\x1b[0m",
|
|
164
|
-
bold: "\x1b[1m"
|
|
189
|
+
bold: "\x1b[1m",
|
|
165
190
|
};
|
|
166
191
|
|
|
167
192
|
// Check if we have the new results format with summary
|
|
@@ -174,71 +199,112 @@ const reporters = {
|
|
|
174
199
|
if (results.summary) {
|
|
175
200
|
// Extract summary data
|
|
176
201
|
const { specs, tests, contexts, steps } = results.summary;
|
|
177
|
-
|
|
202
|
+
|
|
178
203
|
// Calculate totals
|
|
179
|
-
const totalSpecs = specs
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
204
|
+
const totalSpecs = specs
|
|
205
|
+
? specs.pass + specs.fail + specs.warning + specs.skipped
|
|
206
|
+
: 0;
|
|
207
|
+
const totalTests = tests
|
|
208
|
+
? tests.pass + tests.fail + tests.warning + tests.skipped
|
|
209
|
+
: 0;
|
|
210
|
+
const totalContexts = contexts
|
|
211
|
+
? contexts.pass + contexts.fail + contexts.warning + contexts.skipped
|
|
212
|
+
: 0;
|
|
213
|
+
const totalSteps = steps
|
|
214
|
+
? steps.pass + steps.fail + steps.warning + steps.skipped
|
|
215
|
+
: 0;
|
|
216
|
+
|
|
184
217
|
// Any failures overall?
|
|
185
|
-
const hasFailures =
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
218
|
+
const hasFailures =
|
|
219
|
+
(specs && specs.fail > 0) ||
|
|
220
|
+
(tests && tests.fail > 0) ||
|
|
221
|
+
(contexts && contexts.fail > 0) ||
|
|
222
|
+
(steps && steps.fail > 0);
|
|
223
|
+
|
|
224
|
+
console.log(
|
|
225
|
+
`\n${colors.bold}===== Doc Detective Results Summary =====${colors.reset}`
|
|
226
|
+
);
|
|
189
227
|
|
|
190
|
-
console.log(`\n${colors.bold}===== Doc Detective Results Summary =====${colors.reset}`);
|
|
191
|
-
|
|
192
228
|
// Print specs summary if available
|
|
193
229
|
if (specs) {
|
|
194
230
|
console.log(`\n${colors.bold}Specs:${colors.reset}`);
|
|
195
231
|
console.log(`Total: ${totalSpecs}`);
|
|
196
232
|
console.log(`${colors.green}Passed: ${specs.pass}${colors.reset}`);
|
|
197
|
-
console.log(
|
|
198
|
-
|
|
233
|
+
console.log(
|
|
234
|
+
`${specs.fail > 0 ? colors.red : colors.green}Failed: ${specs.fail}${
|
|
235
|
+
colors.reset
|
|
236
|
+
}`
|
|
237
|
+
);
|
|
238
|
+
if (specs.warning > 0)
|
|
239
|
+
console.log(
|
|
240
|
+
`${colors.yellow}Warnings: ${specs.warning}${colors.reset}`
|
|
241
|
+
);
|
|
199
242
|
if (specs.skipped > 0) console.log(`Skipped: ${specs.skipped}`);
|
|
200
243
|
}
|
|
201
|
-
|
|
244
|
+
|
|
202
245
|
// Print tests summary if available
|
|
203
246
|
if (tests) {
|
|
204
247
|
console.log(`\n${colors.bold}Tests:${colors.reset}`);
|
|
205
248
|
console.log(`Total: ${totalTests}`);
|
|
206
249
|
console.log(`${colors.green}Passed: ${tests.pass}${colors.reset}`);
|
|
207
|
-
console.log(
|
|
208
|
-
|
|
250
|
+
console.log(
|
|
251
|
+
`${tests.fail > 0 ? colors.red : colors.green}Failed: ${tests.fail}${
|
|
252
|
+
colors.reset
|
|
253
|
+
}`
|
|
254
|
+
);
|
|
255
|
+
if (tests.warning > 0)
|
|
256
|
+
console.log(
|
|
257
|
+
`${colors.yellow}Warnings: ${tests.warning}${colors.reset}`
|
|
258
|
+
);
|
|
209
259
|
if (tests.skipped > 0) console.log(`Skipped: ${tests.skipped}`);
|
|
210
260
|
}
|
|
211
|
-
|
|
261
|
+
|
|
212
262
|
// Print contexts summary if available
|
|
213
263
|
if (contexts) {
|
|
214
264
|
console.log(`\n${colors.bold}Contexts:${colors.reset}`);
|
|
215
265
|
console.log(`Total: ${totalContexts}`);
|
|
216
266
|
console.log(`${colors.green}Passed: ${contexts.pass}${colors.reset}`);
|
|
217
|
-
console.log(
|
|
218
|
-
|
|
267
|
+
console.log(
|
|
268
|
+
`${contexts.fail > 0 ? colors.red : colors.green}Failed: ${
|
|
269
|
+
contexts.fail
|
|
270
|
+
}${colors.reset}`
|
|
271
|
+
);
|
|
272
|
+
if (contexts.warning > 0)
|
|
273
|
+
console.log(
|
|
274
|
+
`${colors.yellow}Warnings: ${contexts.warning}${colors.reset}`
|
|
275
|
+
);
|
|
219
276
|
if (contexts.skipped > 0) console.log(`Skipped: ${contexts.skipped}`);
|
|
220
277
|
}
|
|
221
|
-
|
|
278
|
+
|
|
222
279
|
// Print steps summary if available
|
|
223
280
|
if (steps) {
|
|
224
281
|
console.log(`\n${colors.bold}Steps:${colors.reset}`);
|
|
225
282
|
console.log(`Total: ${totalSteps}`);
|
|
226
283
|
console.log(`${colors.green}Passed: ${steps.pass}${colors.reset}`);
|
|
227
|
-
console.log(
|
|
228
|
-
|
|
284
|
+
console.log(
|
|
285
|
+
`${steps.fail > 0 ? colors.red : colors.green}Failed: ${steps.fail}${
|
|
286
|
+
colors.reset
|
|
287
|
+
}`
|
|
288
|
+
);
|
|
289
|
+
if (steps.warning > 0)
|
|
290
|
+
console.log(
|
|
291
|
+
`${colors.yellow}Warnings: ${steps.warning}${colors.reset}`
|
|
292
|
+
);
|
|
229
293
|
if (steps.skipped > 0) console.log(`Skipped: ${steps.skipped}`);
|
|
230
294
|
}
|
|
231
295
|
|
|
232
296
|
// If we have specs with failures, display them
|
|
233
297
|
if (results.specs && hasFailures) {
|
|
234
|
-
console.log(
|
|
235
|
-
|
|
298
|
+
console.log(
|
|
299
|
+
`\n${colors.bold}${colors.red}Failed Items:${colors.reset}`
|
|
300
|
+
);
|
|
301
|
+
|
|
236
302
|
// Collect failures
|
|
237
303
|
const failedSpecs = [];
|
|
238
304
|
const failedTests = [];
|
|
239
305
|
const failedContexts = [];
|
|
240
306
|
const failedSteps = [];
|
|
241
|
-
|
|
307
|
+
|
|
242
308
|
// Process specs array to collect failures
|
|
243
309
|
results.specs.forEach((spec, specIndex) => {
|
|
244
310
|
// Check if spec has failed
|
|
@@ -248,7 +314,7 @@ const reporters = {
|
|
|
248
314
|
id: spec.specId || `Spec ${specIndex + 1}`,
|
|
249
315
|
});
|
|
250
316
|
}
|
|
251
|
-
|
|
317
|
+
|
|
252
318
|
// Process tests in this spec
|
|
253
319
|
if (spec.tests && spec.tests.length > 0) {
|
|
254
320
|
spec.tests.forEach((test, testIndex) => {
|
|
@@ -261,12 +327,15 @@ const reporters = {
|
|
|
261
327
|
id: test.testId || `Test ${testIndex + 1}`,
|
|
262
328
|
});
|
|
263
329
|
}
|
|
264
|
-
|
|
330
|
+
|
|
265
331
|
// Process contexts in this test
|
|
266
332
|
if (test.contexts && test.contexts.length > 0) {
|
|
267
333
|
test.contexts.forEach((context, contextIndex) => {
|
|
268
334
|
// Check if context has failed
|
|
269
|
-
if (
|
|
335
|
+
if (
|
|
336
|
+
context.result === "FAIL" ||
|
|
337
|
+
(context.result && context.result.status === "FAIL")
|
|
338
|
+
) {
|
|
270
339
|
failedContexts.push({
|
|
271
340
|
specIndex,
|
|
272
341
|
testIndex,
|
|
@@ -274,10 +343,12 @@ const reporters = {
|
|
|
274
343
|
specId: spec.specId || `Spec ${specIndex + 1}`,
|
|
275
344
|
testId: test.testId || `Test ${testIndex + 1}`,
|
|
276
345
|
platform: context.platform || "unknown",
|
|
277
|
-
browser: context.browser
|
|
346
|
+
browser: context.browser
|
|
347
|
+
? context.browser.name
|
|
348
|
+
: "unknown",
|
|
278
349
|
});
|
|
279
350
|
}
|
|
280
|
-
|
|
351
|
+
|
|
281
352
|
// Process steps in this context
|
|
282
353
|
if (context.steps && context.steps.length > 0) {
|
|
283
354
|
context.steps.forEach((step, stepIndex) => {
|
|
@@ -291,7 +362,9 @@ const reporters = {
|
|
|
291
362
|
specId: spec.specId || `Spec ${specIndex + 1}`,
|
|
292
363
|
testId: test.testId || `Test ${testIndex + 1}`,
|
|
293
364
|
platform: context.platform || "unknown",
|
|
294
|
-
browser: context.browser
|
|
365
|
+
browser: context.browser
|
|
366
|
+
? context.browser.name
|
|
367
|
+
: "unknown",
|
|
295
368
|
stepId: step.stepId || `Step ${stepIndex + 1}`,
|
|
296
369
|
error: step.resultDescription || "Unknown error",
|
|
297
370
|
});
|
|
@@ -303,7 +376,7 @@ const reporters = {
|
|
|
303
376
|
});
|
|
304
377
|
}
|
|
305
378
|
});
|
|
306
|
-
|
|
379
|
+
|
|
307
380
|
// Display failures
|
|
308
381
|
if (failedSpecs.length > 0) {
|
|
309
382
|
console.log(`\n${colors.red}Failed Specs:${colors.reset}`);
|
|
@@ -311,25 +384,37 @@ const reporters = {
|
|
|
311
384
|
console.log(`${colors.red}${i + 1}. ${item.id}${colors.reset}`);
|
|
312
385
|
});
|
|
313
386
|
}
|
|
314
|
-
|
|
387
|
+
|
|
315
388
|
if (failedTests.length > 0) {
|
|
316
389
|
console.log(`\n${colors.red}Failed Tests:${colors.reset}`);
|
|
317
390
|
failedTests.forEach((item, i) => {
|
|
318
|
-
console.log(
|
|
391
|
+
console.log(
|
|
392
|
+
`${colors.red}${i + 1}. ${item.id} (from ${item.specId})${
|
|
393
|
+
colors.reset
|
|
394
|
+
}`
|
|
395
|
+
);
|
|
319
396
|
});
|
|
320
397
|
}
|
|
321
|
-
|
|
398
|
+
|
|
322
399
|
if (failedContexts.length > 0) {
|
|
323
400
|
console.log(`\n${colors.red}Failed Contexts:${colors.reset}`);
|
|
324
401
|
failedContexts.forEach((item, i) => {
|
|
325
|
-
console.log(
|
|
402
|
+
console.log(
|
|
403
|
+
`${colors.red}${i + 1}. ${item.platform}/${item.browser} (from ${
|
|
404
|
+
item.testId
|
|
405
|
+
})${colors.reset}`
|
|
406
|
+
);
|
|
326
407
|
});
|
|
327
408
|
}
|
|
328
|
-
|
|
409
|
+
|
|
329
410
|
if (failedSteps.length > 0) {
|
|
330
411
|
console.log(`\n${colors.red}Failed Steps:${colors.reset}`);
|
|
331
412
|
failedSteps.forEach((item, i) => {
|
|
332
|
-
console.log(
|
|
413
|
+
console.log(
|
|
414
|
+
`${colors.red}${i + 1}. ${item.platform}/${item.browser} - ${
|
|
415
|
+
item.stepId
|
|
416
|
+
}${colors.reset}`
|
|
417
|
+
);
|
|
333
418
|
console.log(` Error: ${item.error}`);
|
|
334
419
|
});
|
|
335
420
|
}
|
|
@@ -337,12 +422,14 @@ const reporters = {
|
|
|
337
422
|
// Celebration when all tests pass
|
|
338
423
|
console.log(`\n${colors.green}🎉 All items passed! 🎉${colors.reset}`);
|
|
339
424
|
}
|
|
340
|
-
}
|
|
341
|
-
console.log(
|
|
425
|
+
} else {
|
|
426
|
+
console.log(
|
|
427
|
+
"No tests were executed or results are in an unknown format."
|
|
428
|
+
);
|
|
342
429
|
}
|
|
343
|
-
|
|
430
|
+
|
|
344
431
|
console.log("\n===============================\n");
|
|
345
|
-
}
|
|
432
|
+
},
|
|
346
433
|
};
|
|
347
434
|
|
|
348
435
|
// Export reporters for external use
|
|
@@ -350,8 +437,8 @@ exports.reporters = reporters;
|
|
|
350
437
|
|
|
351
438
|
// Helper function to register custom reporters
|
|
352
439
|
function registerReporter(name, reporterFunction) {
|
|
353
|
-
if (typeof reporterFunction !==
|
|
354
|
-
throw new Error(
|
|
440
|
+
if (typeof reporterFunction !== "function") {
|
|
441
|
+
throw new Error("Reporter must be a function");
|
|
355
442
|
}
|
|
356
443
|
reporters[name] = reporterFunction;
|
|
357
444
|
return true;
|
|
@@ -362,21 +449,21 @@ exports.registerReporter = registerReporter;
|
|
|
362
449
|
|
|
363
450
|
async function outputResults(config = {}, outputPath, results, options = {}) {
|
|
364
451
|
// Default to using both built-in reporters if none specified
|
|
365
|
-
const defaultReporters = [
|
|
366
|
-
|
|
452
|
+
const defaultReporters = ["terminal", "json"];
|
|
453
|
+
|
|
367
454
|
let activeReporters = options.reporters || defaultReporters;
|
|
368
|
-
|
|
455
|
+
|
|
369
456
|
// If the reporters option is provided as strings, normalize them
|
|
370
457
|
if (activeReporters.length > 0) {
|
|
371
458
|
// Convert any shorthand names to full reporter names
|
|
372
|
-
activeReporters = activeReporters.map(reporter => {
|
|
373
|
-
if (typeof reporter ===
|
|
459
|
+
activeReporters = activeReporters.map((reporter) => {
|
|
460
|
+
if (typeof reporter === "string") {
|
|
374
461
|
// Convert shorthand names to actual reporter keys
|
|
375
462
|
switch (reporter.toLowerCase()) {
|
|
376
|
-
case
|
|
377
|
-
return
|
|
378
|
-
case
|
|
379
|
-
return
|
|
463
|
+
case "json":
|
|
464
|
+
return "jsonReporter";
|
|
465
|
+
case "terminal":
|
|
466
|
+
return "terminalReporter";
|
|
380
467
|
default:
|
|
381
468
|
return reporter;
|
|
382
469
|
}
|
|
@@ -386,15 +473,19 @@ async function outputResults(config = {}, outputPath, results, options = {}) {
|
|
|
386
473
|
}
|
|
387
474
|
|
|
388
475
|
// Execute each reporter
|
|
389
|
-
const reporterPromises = activeReporters.map(reporter => {
|
|
390
|
-
if (typeof reporter ===
|
|
476
|
+
const reporterPromises = activeReporters.map((reporter) => {
|
|
477
|
+
if (typeof reporter === "function") {
|
|
391
478
|
// Direct function reference
|
|
392
479
|
return reporter(config, outputPath, results, options);
|
|
393
|
-
} else if (typeof reporter ===
|
|
480
|
+
} else if (typeof reporter === "string" && reporters[reporter]) {
|
|
394
481
|
// String reference to built-in or registered reporter
|
|
395
482
|
return reporters[reporter](config, outputPath, results, options);
|
|
396
|
-
} else if (typeof reporter ===
|
|
397
|
-
console.error(
|
|
483
|
+
} else if (typeof reporter === "string" && !reporters[reporter]) {
|
|
484
|
+
console.error(
|
|
485
|
+
`Reporter "${reporter}" not found. Available reporters: ${Object.keys(
|
|
486
|
+
reporters
|
|
487
|
+
).join(", ")}`
|
|
488
|
+
);
|
|
398
489
|
return Promise.resolve();
|
|
399
490
|
} else {
|
|
400
491
|
console.error(`Invalid reporter: ${reporter}`);
|
package/test/utils.test.js
CHANGED
|
@@ -67,12 +67,15 @@ describe("Util tests", function () {
|
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
// Test that config overrides are set correctly
|
|
70
|
-
it("Config overrides are set correctly", function () {
|
|
70
|
+
it("Config overrides are set correctly", async function () {
|
|
71
|
+
// This test takes a bit longer
|
|
72
|
+
this.timeout(5000);
|
|
73
|
+
|
|
71
74
|
configSets = [
|
|
72
75
|
{
|
|
73
76
|
// Input override
|
|
74
77
|
args: ["node", "runTests.js", "--input", "input.spec.json"],
|
|
75
|
-
expected: { input: "input.spec.json" },
|
|
78
|
+
expected: { input: [path.resolve(process.cwd(), "input.spec.json")] },
|
|
76
79
|
},
|
|
77
80
|
{
|
|
78
81
|
// Input and logLevel overrides
|
|
@@ -84,7 +87,7 @@ describe("Util tests", function () {
|
|
|
84
87
|
"--logLevel",
|
|
85
88
|
"debug",
|
|
86
89
|
],
|
|
87
|
-
expected: { input: "input.spec.json", logLevel: "debug" },
|
|
90
|
+
expected: { input: [path.resolve(process.cwd(), "input.spec.json")], logLevel: "debug" },
|
|
88
91
|
},
|
|
89
92
|
{
|
|
90
93
|
// Input, logLevel, and setup overrides
|
|
@@ -97,7 +100,7 @@ describe("Util tests", function () {
|
|
|
97
100
|
"debug",
|
|
98
101
|
],
|
|
99
102
|
expected: {
|
|
100
|
-
input: "input.spec.json",
|
|
103
|
+
input: [path.resolve(process.cwd(), "input.spec.json")],
|
|
101
104
|
logLevel: "debug",
|
|
102
105
|
},
|
|
103
106
|
},
|
|
@@ -105,8 +108,7 @@ describe("Util tests", function () {
|
|
|
105
108
|
// Referenced config without overrides
|
|
106
109
|
args: ["node", "runTests.js", "--config", "./test/test-config.json"],
|
|
107
110
|
expected: {
|
|
108
|
-
input:
|
|
109
|
-
output: ".",
|
|
111
|
+
input: process.cwd(),
|
|
110
112
|
logLevel: "silent",
|
|
111
113
|
recursive: true,
|
|
112
114
|
},
|
|
@@ -122,18 +124,42 @@ describe("Util tests", function () {
|
|
|
122
124
|
"input.spec.json",
|
|
123
125
|
],
|
|
124
126
|
expected: {
|
|
125
|
-
input: "input.spec.json",
|
|
126
|
-
output: ".",
|
|
127
|
+
input: [path.resolve(process.cwd(), "input.spec.json")],
|
|
127
128
|
logLevel: "silent",
|
|
128
129
|
recursive: true,
|
|
129
130
|
},
|
|
130
131
|
},
|
|
132
|
+
{
|
|
133
|
+
// Multiple inputs
|
|
134
|
+
args: [
|
|
135
|
+
"node",
|
|
136
|
+
"runTests.js",
|
|
137
|
+
"--config",
|
|
138
|
+
"./test/test-config.json",
|
|
139
|
+
"--input",
|
|
140
|
+
"input.spec.json,anotherInput.spec.json",
|
|
141
|
+
],
|
|
142
|
+
expected: {
|
|
143
|
+
input: [path.resolve(process.cwd(), "input.spec.json"), path.resolve(process.cwd(), "anotherInput.spec.json")],
|
|
144
|
+
output: process.cwd(),
|
|
145
|
+
recursive: true,
|
|
146
|
+
},
|
|
147
|
+
}
|
|
131
148
|
];
|
|
132
149
|
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
// Use process.stdout.write directly to force console output during tests
|
|
151
|
+
console.log('\n===== CONFIG TEST RESULTS =====\n');
|
|
152
|
+
|
|
153
|
+
// Use Promise.all with map instead of forEach to properly handle async operations
|
|
154
|
+
await Promise.all(configSets.map(async (configSet, index) => {
|
|
155
|
+
// Set config with the args
|
|
156
|
+
console.log(`Config test ${index}: ${JSON.stringify(configSet, null, 2)}`);
|
|
157
|
+
const configResult = await setConfig({ args: setArgs(configSet.args) });
|
|
158
|
+
console.log(`Config result ${index}: ${JSON.stringify(configResult, null, 2)}\n`);
|
|
159
|
+
// Deeply compare the config result with the expected result
|
|
135
160
|
deepObjectExpect(configResult, configSet.expected);
|
|
136
|
-
});
|
|
161
|
+
}));
|
|
162
|
+
process.stdout.write('===== END CONFIG TEST RESULTS =====\n');
|
|
137
163
|
});
|
|
138
164
|
|
|
139
165
|
// Test that results output correctly.
|
|
@@ -157,11 +183,33 @@ describe("Util tests", function () {
|
|
|
157
183
|
function deepObjectExpect(actual, expected) {
|
|
158
184
|
// Check that actual has all the keys of expected
|
|
159
185
|
Object.entries(expected).forEach(([key, value]) => {
|
|
160
|
-
//
|
|
161
|
-
|
|
186
|
+
// Make sure the property exists in actual
|
|
187
|
+
expect(actual).to.have.property(key);
|
|
188
|
+
|
|
189
|
+
// If value is null, check directly
|
|
190
|
+
if (value === null) {
|
|
191
|
+
expect(actual[key]).to.equal(null);
|
|
192
|
+
}
|
|
193
|
+
// If value is an array, check each item
|
|
194
|
+
else if (Array.isArray(value)) {
|
|
195
|
+
expect(Array.isArray(actual[key])).to.equal(true, `Expected ${key} to be an array. Expected: ${expected[key]}. Actual: ${actual[key]}.`);
|
|
196
|
+
expect(actual[key].length).to.equal(value.length, `Expected ${key} array to have length ${value.length}. Actual: ${actual[key].length}`);
|
|
197
|
+
|
|
198
|
+
// Check each array item
|
|
199
|
+
value.forEach((item, index) => {
|
|
200
|
+
if (typeof item === "object" && item !== null) {
|
|
201
|
+
deepObjectExpect(actual[key][index], item);
|
|
202
|
+
} else {
|
|
203
|
+
expect(actual[key][index]).to.equal(item);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// If value is an object but not null, recursively check it
|
|
208
|
+
else if (typeof value === "object") {
|
|
162
209
|
deepObjectExpect(actual[key], expected[key]);
|
|
163
|
-
}
|
|
164
|
-
|
|
210
|
+
}
|
|
211
|
+
// Otherwise, check that the value is correct
|
|
212
|
+
else {
|
|
165
213
|
const expectedObject = {};
|
|
166
214
|
expectedObject[key] = value;
|
|
167
215
|
expect(actual).to.deep.include(expectedObject);
|
package/samples/http.spec.json
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"tests": [
|
|
3
|
-
{
|
|
4
|
-
"steps": [
|
|
5
|
-
{
|
|
6
|
-
"action": "checkLink",
|
|
7
|
-
"url": "https://www.duckduckgo.com"
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
"action": "httpRequest",
|
|
11
|
-
"url": "https://reqres.in/api/users",
|
|
12
|
-
"method": "post",
|
|
13
|
-
"requestData": {
|
|
14
|
-
"name": "morpheus",
|
|
15
|
-
"job": "leader"
|
|
16
|
-
},
|
|
17
|
-
"responseData": {
|
|
18
|
-
"name": "morpheus",
|
|
19
|
-
"job": "leader"
|
|
20
|
-
},
|
|
21
|
-
"statusCodes": [200, 201]
|
|
22
|
-
}
|
|
23
|
-
]
|
|
24
|
-
}
|
|
25
|
-
]
|
|
26
|
-
}
|