pa11y-ci-reporter-runner 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/index.js +125 -0
- package/lib/config.js +54 -0
- package/lib/default-config.js +17 -0
- package/lib/formatter.js +66 -0
- package/lib/reporterBuilder.js +52 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Aaron Goldenthal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Pa11y CI Reporter Runner
|
|
2
|
+
|
|
3
|
+
Pa11y CI Reporter Runner is designed to facilitate testing of [Pa11y CI reporters](https://github.com/pa11y/pa11y-ci#write-a-custom-reporter). Given a Pa11y CI JSON results file and optional configuration it simulates the Pa11y CI calls to the reporter, including proper tranformation of results and configuration data. Functionally, it's a mock of the Pa11y CI side of the reporter interface.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install Pa11y CI Reporter Runner via [npm](https://www.npmjs.com/package/pa11y-ci-reporter-runner).
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install pa11y-ci-reporter-runner
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Pa11y CI Reporter Runner exports a factory function that creates a reporter runner. This function has four arguments:
|
|
16
|
+
|
|
17
|
+
- `resultsFileName`: Path to a Pa11y CI JSON results file.
|
|
18
|
+
- `reporterName`: Name of the reporter to execute. Can be a dependency (e.g. `pa11y-ci-reporter-html`) or a path to a reporter file.
|
|
19
|
+
- `options`: Optional [reporter options](https://github.com/pa11y/pa11y-ci#reporter-options).
|
|
20
|
+
- `config`: Optional Pa11y CI configuration file that produces the Pa11y CI JSON results.
|
|
21
|
+
|
|
22
|
+
The reporter runner currently has one function:
|
|
23
|
+
|
|
24
|
+
- `runAll`: Simulates Pa11y CI running the complete analysis from the provided JSON results file, calling all associated reporter functions (`beforeAll`, `begin` and `results`/`error` for each URL, `afterAll`).
|
|
25
|
+
|
|
26
|
+
A complete example is provided below:
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
const createRunner = require("pa11y-ci-reporter-runner");
|
|
30
|
+
|
|
31
|
+
const resultsFileName = "pa11yci-results.json";
|
|
32
|
+
const reporterName = "../test-reporter.js";
|
|
33
|
+
const reporterOptions = { "isSomething": true };
|
|
34
|
+
const config = {
|
|
35
|
+
defaults: {
|
|
36
|
+
timeout: 30000,
|
|
37
|
+
},
|
|
38
|
+
urls: [
|
|
39
|
+
"http://localhost:8080/page1-with-errors.html",
|
|
40
|
+
"http://localhost:8080/page1-no-errors.html",
|
|
41
|
+
{
|
|
42
|
+
url: "https://pa11y.org/timed-out.html",
|
|
43
|
+
timeout: 50,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
test('test reporter', async () => {
|
|
49
|
+
const runner = createRunner(resultsFileName, reporterName, reporterOptions, config);
|
|
50
|
+
|
|
51
|
+
await runner.runAll();
|
|
52
|
+
|
|
53
|
+
// Test reporter results
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Limitations
|
|
58
|
+
|
|
59
|
+
When passing config to `results`, `error`, and `afterAll`, Pa11y CI Reporter Runner includes the same properties as Pa11y CI except the `browser` property (with the `puppeteer` `browser` object used by Pa11y CI). If the `browser` object is needed, testing should be done with Pa11y CI to ensure proper `browser` configuration.
|
package/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pa11y CI Reporter Runner allows a pa11y-ci reporter to be run
|
|
5
|
+
* from a JSON results file without using pa11y-ci.
|
|
6
|
+
*
|
|
7
|
+
* @module pa11y-ci-reporter-runner
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const formatter = require('./lib/formatter');
|
|
12
|
+
const reporterBuilder = require('./lib/reporterBuilder');
|
|
13
|
+
const createConfig = require('./lib/config');
|
|
14
|
+
|
|
15
|
+
const loadPa11yciResults = fileName => {
|
|
16
|
+
if (typeof fileName !== 'string') {
|
|
17
|
+
throw new TypeError('fileName must be a string');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const results = JSON.parse(fs.readFileSync(fileName, 'utf8'));
|
|
22
|
+
return formatter.convertJsonToResultsObject(results);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
throw new Error(`Error loading results file - ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const isError = results => results.length === 1 && results[0] instanceof Error;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pa11y CI configuration allows config.urls entries to be either url strings
|
|
33
|
+
* or object with url and other configuration, so this return a list of just
|
|
34
|
+
* the url strings.
|
|
35
|
+
*
|
|
36
|
+
* @private
|
|
37
|
+
* @static
|
|
38
|
+
* @param {object[]} values The urls array from configuration.
|
|
39
|
+
* @returns {string[]} Array of URL strings.
|
|
40
|
+
*/
|
|
41
|
+
const getUrlList = values => {
|
|
42
|
+
const result = [];
|
|
43
|
+
for (const value of values) {
|
|
44
|
+
if (typeof value === 'string') {
|
|
45
|
+
result.push(value);
|
|
46
|
+
}
|
|
47
|
+
else if (typeof value.url === 'string') {
|
|
48
|
+
result.push(value.url);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
throw new TypeError('invalid url element');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compares URLs in Pa1y CI results and configuration files to ensure
|
|
59
|
+
* consistency (i.e. The same URLs are in both lists, although they
|
|
60
|
+
* may not be in the same order). URLs in config are only checked if
|
|
61
|
+
* provided. If specified and inconsistent, throws error.
|
|
62
|
+
*
|
|
63
|
+
* @private
|
|
64
|
+
* @static
|
|
65
|
+
* @param {object} results Pa11y CI JSON results file.
|
|
66
|
+
* @param {object} config Pa11y CI configuration.
|
|
67
|
+
*/
|
|
68
|
+
const validateUrls = (results, config) => {
|
|
69
|
+
// Valid if no urls specified in config
|
|
70
|
+
if (!config.urls) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const resultUrls = Object.keys(results.results);
|
|
74
|
+
const configUrls = getUrlList(config.urls);
|
|
75
|
+
if (resultUrls.length !== configUrls.length ||
|
|
76
|
+
JSON.stringify(resultUrls.sort()) !== JSON.stringify(configUrls.sort())) {
|
|
77
|
+
throw new TypeError('config.urls is specified and does not match results');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Factory to create a pa11y-ci reporter runner that can execute
|
|
83
|
+
* a reporter with the specified pa11y-ci JSON results file.
|
|
84
|
+
*
|
|
85
|
+
* @public
|
|
86
|
+
* @static
|
|
87
|
+
* @param {string} resultsFileName Pa11y CI JSON results file.
|
|
88
|
+
* @param {string} reporterName Name of the reporter to execute (module or path).
|
|
89
|
+
* @param {object} options The reporter options.
|
|
90
|
+
* @param {object} config The Pa11y CI configuration.
|
|
91
|
+
* @returns {object} A Pa11y CI reporter runner.
|
|
92
|
+
*/
|
|
93
|
+
const createRunner = (resultsFileName, reporterName, options = {}, config = {}) => {
|
|
94
|
+
const pa11yciResults = loadPa11yciResults(resultsFileName);
|
|
95
|
+
|
|
96
|
+
validateUrls(pa11yciResults, config);
|
|
97
|
+
|
|
98
|
+
const pa11yciConfig = createConfig(config);
|
|
99
|
+
const reporter = reporterBuilder.buildReporter(reporterName, options, pa11yciConfig.defaults);
|
|
100
|
+
|
|
101
|
+
const runAll = async () => {
|
|
102
|
+
await reporter.beforeAll(config.urls || Object.keys(pa11yciResults.results));
|
|
103
|
+
|
|
104
|
+
for (const url of Object.keys(pa11yciResults.results)) {
|
|
105
|
+
await reporter.begin(url);
|
|
106
|
+
|
|
107
|
+
const urlResults = pa11yciResults.results[url];
|
|
108
|
+
const urlConfig = pa11yciConfig.getConfigForUrl(url);
|
|
109
|
+
if (isError(urlResults)) {
|
|
110
|
+
await reporter.error(urlResults[0], url, urlConfig);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
await reporter.results(formatter.getPa11yResultsFromPa11yCiResults(url, pa11yciResults), urlConfig);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await reporter.afterAll(pa11yciResults, pa11yciConfig.defaults);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
runAll
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
module.exports = createRunner;
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Manages Pa11y CI configuration.
|
|
5
|
+
*
|
|
6
|
+
* @module config
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { defaultsDeep, omit } = require('lodash');
|
|
10
|
+
|
|
11
|
+
const defaultConfig = require('./default-config');
|
|
12
|
+
|
|
13
|
+
const normalizeConfig = (config) => {
|
|
14
|
+
// Remove log and reporters to be consistent with pa11y-ci
|
|
15
|
+
const configDefaults = omit(config.defaults || {}, ['log', 'reporters']);
|
|
16
|
+
return {
|
|
17
|
+
defaults: defaultsDeep({}, configDefaults, defaultConfig),
|
|
18
|
+
urls: config.urls || []
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Factory function that returns a config object for managing PA11y CI
|
|
24
|
+
* configuration including defaults and determining the consolidated
|
|
25
|
+
* configuration for each URL.
|
|
26
|
+
*
|
|
27
|
+
* @static
|
|
28
|
+
* @param {object} config The Pa11y CI configuration.
|
|
29
|
+
* @returns {object} Config object.
|
|
30
|
+
*/
|
|
31
|
+
const configFactory = (config) => {
|
|
32
|
+
const { defaults, urls } = normalizeConfig(config);
|
|
33
|
+
|
|
34
|
+
const getConfigForUrl = (url) => {
|
|
35
|
+
if (!urls || urls.length === 0) {
|
|
36
|
+
return defaults;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Config URLs are validated against results, if specified, so will find a result.
|
|
40
|
+
const result = urls.find(urlObject => urlObject === url || urlObject.url === url);
|
|
41
|
+
if (typeof result === 'string') {
|
|
42
|
+
return defaults;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return defaultsDeep({}, result, defaults);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
defaults,
|
|
50
|
+
getConfigForUrl
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
module.exports = configFactory;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default pa11y-ci configuration.
|
|
5
|
+
*
|
|
6
|
+
* @module default-config
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const defaultColumnWidth = 80;
|
|
10
|
+
|
|
11
|
+
const defaultConfig = {
|
|
12
|
+
wrapWidth: process.stdout.columns || defaultColumnWidth,
|
|
13
|
+
concurrency: 1,
|
|
14
|
+
useIncognitoBrowserContext: true
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
module.exports = defaultConfig;
|
package/lib/formatter.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reformats pa11y-ci JSON data.
|
|
5
|
+
*
|
|
6
|
+
* @module formatter
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts Pa11y CI JSON output to an equivalent Pa11y CI object,
|
|
11
|
+
* allowing JSON result files to be used for reporter interface
|
|
12
|
+
* testing. Error messages are converted to Error objects.
|
|
13
|
+
*
|
|
14
|
+
* @static
|
|
15
|
+
* @param {object} jsonResults Pa11y CI JSON results.
|
|
16
|
+
* @returns {object} The equivalent Pa11y CI object.
|
|
17
|
+
*/
|
|
18
|
+
const convertJsonToResultsObject = jsonResults => {
|
|
19
|
+
const results = {
|
|
20
|
+
total: jsonResults.total,
|
|
21
|
+
passes: jsonResults.passes,
|
|
22
|
+
errors: jsonResults.errors,
|
|
23
|
+
results: {}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
for (const url of Object.keys(jsonResults.results)) {
|
|
27
|
+
const issues = jsonResults.results[url];
|
|
28
|
+
let formattedIssues;
|
|
29
|
+
if (issues.length === 1 && Object.keys(issues[0]).length === 1 && issues[0].message) {
|
|
30
|
+
formattedIssues = [new Error(issues[0].message)];
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
formattedIssues = issues;
|
|
34
|
+
}
|
|
35
|
+
results.results[url] = formattedIssues;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return results;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Checks the given Pa11y CI results for the given URL, and if found
|
|
43
|
+
* returns a Pa11y results object of the format sent to the reporter
|
|
44
|
+
* results event. Throws if the url is not found in the results.
|
|
45
|
+
*
|
|
46
|
+
* @static
|
|
47
|
+
* @param {string} url The URL to find results for,.
|
|
48
|
+
* @param {object} results Pa11y CI results object.
|
|
49
|
+
* @returns {object} The Pa11y results object for the URL.
|
|
50
|
+
*/
|
|
51
|
+
const getPa11yResultsFromPa11yCiResults = (url, results) => {
|
|
52
|
+
const issues = results.results[url];
|
|
53
|
+
if (!issues) {
|
|
54
|
+
throw new Error(`Results for url "${url} not found`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
documentTitle: `Title for ${url}`,
|
|
59
|
+
pageUrl: url,
|
|
60
|
+
issues
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
module.exports.convertJsonToResultsObject = convertJsonToResultsObject;
|
|
65
|
+
module.exports.getPa11yResultsFromPa11yCiResults = getPa11yResultsFromPa11yCiResults;
|
|
66
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const reporterMethods = ['beforeAll', 'begin', 'results', 'error', 'afterAll'];
|
|
6
|
+
const noop = () => {};
|
|
7
|
+
|
|
8
|
+
const loadReporter = reporterName => {
|
|
9
|
+
try {
|
|
10
|
+
// eslint-disable-next-line node/global-require
|
|
11
|
+
return require(reporterName);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// eslint-disable-next-line node/global-require
|
|
15
|
+
return require(path.resolve(process.cwd(), reporterName));
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates a reporter object for the specified path, following the logic
|
|
21
|
+
* used by pa11y-ci for reporter generation. Unlike pa11y-ci, ensures a
|
|
22
|
+
* function exists for each reporter event to allow manual execution.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} reporterName Name of the reporter to execute
|
|
25
|
+
* (module or path).
|
|
26
|
+
* @param {object} options The reporter options.
|
|
27
|
+
* @param {object} config The Pa11y CI configuration.
|
|
28
|
+
* @returns {object} The reporter object.
|
|
29
|
+
*/
|
|
30
|
+
const buildReporter = (reporterName, options, config) => {
|
|
31
|
+
if (typeof reporterName !== 'string') {
|
|
32
|
+
throw new TypeError('reporterName must be a string');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
let reporter = loadReporter(reporterName);
|
|
37
|
+
if (typeof reporter === 'function') {
|
|
38
|
+
reporter = reporter(options, config);
|
|
39
|
+
}
|
|
40
|
+
reporterMethods.forEach(method => {
|
|
41
|
+
if (typeof reporter[method] !== 'function') {
|
|
42
|
+
reporter[method] = noop;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return reporter;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new Error(`Error loading reporter: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
module.exports.buildReporter = buildReporter;
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pa11y-ci-reporter-runner",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Pa11y CI Reporter Runner is designed to facilitate testing of Pa11y CI reporters. Given a Pa11y CI JSON results file and optional configuration it simulates the Pa11y CI calls to the reporter.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest --ci",
|
|
8
|
+
"lint-js": "eslint .",
|
|
9
|
+
"lint-md": "markdownlint **/*.md --ignore node_modules --ignore Archive",
|
|
10
|
+
"lint": "npm run lint-js && npm run lint-md",
|
|
11
|
+
"push": "npm run lint && npm audit --audit-level=high && npm test"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://gitlab.com/gitlab-ci-utils/pa11y-ci-reporter-runner.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pa11y-ci",
|
|
19
|
+
"pa11y",
|
|
20
|
+
"reporter",
|
|
21
|
+
"test",
|
|
22
|
+
"testing"
|
|
23
|
+
],
|
|
24
|
+
"author": "Aaron Goldenthal <npm@aarongoldenthal.com>",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": "^12.20.0 || ^14.15.0 || >=16.0.0"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.js",
|
|
31
|
+
"lib/"
|
|
32
|
+
],
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://gitlab.com/gitlab-ci-utils/pa11y-ci-reporter-runner/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://gitlab.com/gitlab-ci-utils/pa11y-ci-reporter-runner#readme",
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@aarongoldenthal/eslint-config-standard": "^11.0.0",
|
|
39
|
+
"eslint": "^8.8.0",
|
|
40
|
+
"jest": "^27.4.7",
|
|
41
|
+
"jest-junit": "^13.0.0",
|
|
42
|
+
"markdownlint-cli": "^0.30.0",
|
|
43
|
+
"pa11y-ci-reporter-cli-summary": "^1.0.1"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"lodash": "^4.17.21"
|
|
47
|
+
}
|
|
48
|
+
}
|