@smartbear/one-report-publisher
Advanced tools
Comparing version
@@ -58,3 +58,3 @@ "use strict"; | ||
return __awaiter(this, void 0, void 0, function () { | ||
var organizationId, password, globs, baseUrl, responseBodies; | ||
var organizationId, password, globs, baseUrl, zip, responseBodies; | ||
return __generator(this, function (_a) { | ||
@@ -67,3 +67,4 @@ switch (_a.label) { | ||
baseUrl = core_1.default.getInput('url'); | ||
return [4 /*yield*/, (0, index_js_1.publish)(globs, organizationId, baseUrl, process.env, (0, index_js_1.vercelAuthenticator)(baseUrl, password))]; | ||
zip = core_1.default.getBooleanInput('zip'); | ||
return [4 /*yield*/, (0, index_js_1.publish)(globs, zip, organizationId, baseUrl, process.env, (0, index_js_1.vercelAuthenticator)(baseUrl, password))]; | ||
case 1: | ||
@@ -70,0 +71,0 @@ responseBodies = _a.sent(); |
@@ -59,5 +59,6 @@ #!/usr/bin/env node | ||
program.option('-u, --url <url>', 'OneReport URL', 'https://one-report.vercel.app'); | ||
program.option('--no-zip', 'Do not zip non .zip files', false); | ||
function main() { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var _a, organizationId, password, globs, baseUrl, responseBodies; | ||
var _a, organizationId, password, globs, baseUrl, noZip, responseBodies; | ||
return __generator(this, function (_b) { | ||
@@ -67,4 +68,4 @@ switch (_b.label) { | ||
program.parse(process.argv); | ||
_a = program.opts(), organizationId = _a.organizationId, password = _a.password, globs = _a.reports, baseUrl = _a.url; | ||
return [4 /*yield*/, (0, index_js_1.publish)(globs, organizationId, baseUrl, process.env, (0, index_js_1.vercelAuthenticator)(baseUrl, password))]; | ||
_a = program.opts(), organizationId = _a.organizationId, password = _a.password, globs = _a.reports, baseUrl = _a.url, noZip = _a.noZip; | ||
return [4 /*yield*/, (0, index_js_1.publish)(globs, !noZip, organizationId, baseUrl, process.env, (0, index_js_1.vercelAuthenticator)(baseUrl, password))]; | ||
case 1: | ||
@@ -71,0 +72,0 @@ responseBodies = _b.sent(); |
@@ -7,2 +7,3 @@ import { Env } from '@cucumber/ci-environment'; | ||
* @param globs a list of globs pointing to JUnit XML and Cucumber JSON files | ||
* @param zip if true, compress all non .zip files into a zip file before publishing | ||
* @param organizationId the Organization ID on OneReport | ||
@@ -14,3 +15,3 @@ * @param baseUrl the base URL of OneReport (e.g. https://one-report.vercel.app/) | ||
*/ | ||
export declare function publish<ResponseBody>(globs: readonly string[], organizationId: string, baseUrl: string, env: Env, authenticate: Authenticate): Promise<readonly ResponseBody[]>; | ||
export declare function publish<ResponseBody>(globs: readonly string[], zip: boolean, organizationId: string, baseUrl: string, env: Env, authenticate: Authenticate): Promise<readonly ResponseBody[]>; | ||
//# sourceMappingURL=publish.d.ts.map |
@@ -80,2 +80,3 @@ "use strict"; | ||
var readStream_js_1 = require("./readStream.js"); | ||
var zipPaths_js_1 = require("./zipPaths.js"); | ||
var lstat = (0, util_1.promisify)(fs_1.default.lstat); | ||
@@ -93,2 +94,3 @@ var extensions = ['.xml', '.json', '.ndjson', '.zip']; | ||
* @param globs a list of globs pointing to JUnit XML and Cucumber JSON files | ||
* @param zip if true, compress all non .zip files into a zip file before publishing | ||
* @param organizationId the Organization ID on OneReport | ||
@@ -100,7 +102,7 @@ * @param baseUrl the base URL of OneReport (e.g. https://one-report.vercel.app/) | ||
*/ | ||
function publish(globs, organizationId, baseUrl, env, authenticate) { | ||
function publish(globs, zip, organizationId, baseUrl, env, authenticate) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var authHeaders, url, ciEnv, paths; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
var authHeaders, url, ciEnv, paths, publishPaths, _a; | ||
return __generator(this, function (_b) { | ||
switch (_b.label) { | ||
case 0: | ||
@@ -115,3 +117,3 @@ if (!Array.isArray(globs)) { | ||
case 1: | ||
authHeaders = _a.sent(); | ||
authHeaders = _b.sent(); | ||
url = new url_1.URL("/api/organization/".concat(encodeURIComponent(organizationId), "/execution"), baseUrl); | ||
@@ -121,3 +123,3 @@ ciEnv = (0, ci_environment_1.default)(env); | ||
case 2: | ||
paths = (_a.sent()) | ||
paths = (_b.sent()) | ||
.filter(function (path) { return extensions.includes((0, path_1.extname)(path)); }) | ||
@@ -128,3 +130,13 @@ .sort(); | ||
} | ||
return [2 /*return*/, Promise.all(paths.map(function (path) { return publishFile(path, url, ciEnv, authHeaders); }))]; | ||
if (!zip) return [3 /*break*/, 4]; | ||
return [4 /*yield*/, (0, zipPaths_js_1.zipPaths)(paths)]; | ||
case 3: | ||
_a = _b.sent(); | ||
return [3 /*break*/, 5]; | ||
case 4: | ||
_a = paths; | ||
_b.label = 5; | ||
case 5: | ||
publishPaths = _a; | ||
return [2 /*return*/, Promise.all(publishPaths.map(function (path) { return publishFile(path, url, ciEnv, authHeaders); }))]; | ||
} | ||
@@ -131,0 +143,0 @@ }); |
@@ -71,3 +71,3 @@ "use strict"; | ||
baseUrl = "https://one-report.vercel.app"; | ||
return [4 /*yield*/, (0, index_js_1.publish)(['test/fixtures/*.{xml,json}'], process.env.ONE_REPORT_TEST_ORGANIZATION_ID, baseUrl, process.env, (0, index_js_1.vercelAuthenticator)(baseUrl, process.env.ONE_REPORT_PASSWORD))]; | ||
return [4 /*yield*/, (0, index_js_1.publish)(['test/fixtures/*.{xml,json}'], true, process.env.ONE_REPORT_TEST_ORGANIZATION_ID, baseUrl, process.env, (0, index_js_1.vercelAuthenticator)(baseUrl, process.env.ONE_REPORT_PASSWORD))]; | ||
case 1: | ||
@@ -74,0 +74,0 @@ responseBodies = _b.sent(); |
@@ -97,4 +97,4 @@ "use strict"; | ||
}); }); | ||
it('publishes files from glob', function () { return __awaiter(void 0, void 0, void 0, function () { | ||
var organizationId, fakeEnv, responseBodies, expectedServerRequests, _a, _b, _c, _d, _e, _f, _g, _h, _j, sortByContentType, sortedServerRequests, sortedExpectedServerRequests, expectedResponseBodies; | ||
it('publishes files from glob without zipping', function () { return __awaiter(void 0, void 0, void 0, function () { | ||
var organizationId, fakeEnv, responseBodies, expectedServerRequests, _a, _b, _c, _d, _e, _f, _g, _h, _j, sortedServerRequests, sortedExpectedServerRequests, expectedResponseBodies; | ||
var _k, _l, _m, _o, _p, _q, _r, _s; | ||
@@ -112,3 +112,3 @@ return __generator(this, function (_t) { | ||
}; | ||
return [4 /*yield*/, (0, index_js_1.publish)(['test/fixtures/*.{xml,json,ndjson,zip}'], organizationId, "http://localhost:".concat(port), fakeEnv, function () { return Promise.resolve({}); })]; | ||
return [4 /*yield*/, (0, index_js_1.publish)(['test/fixtures/*.{xml,json,ndjson,zip}'], false, organizationId, "http://localhost:".concat(port), fakeEnv, function () { return Promise.resolve({}); })]; | ||
case 1: | ||
@@ -208,5 +208,2 @@ responseBodies = _t.sent(); | ||
]); | ||
sortByContentType = function (a, b) { | ||
return a.headers['content-type'].localeCompare(b.headers['content-type']); | ||
}; | ||
sortedServerRequests = serverRequests.sort(sortByContentType); | ||
@@ -234,3 +231,60 @@ sortedExpectedServerRequests = expectedServerRequests.sort(sortByContentType); | ||
}); }); | ||
it('publishes files from glob with zipping', function () { return __awaiter(void 0, void 0, void 0, function () { | ||
var organizationId, fakeEnv, responseBodies, expectedServerRequests, sortedServerRequests, sortedExpectedServerRequests, expectedResponseBodies; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
organizationId = '32C46057-0AB6-44E8-8944-0246E0BEA96F'; | ||
fakeEnv = {}; | ||
return [4 /*yield*/, (0, index_js_1.publish)(['test/fixtures/*.{xml,json,ndjson,zip}'], true, organizationId, "http://localhost:".concat(port), fakeEnv, function () { return Promise.resolve({}); })]; | ||
case 1: | ||
responseBodies = _a.sent(); | ||
expectedServerRequests = [ | ||
{ | ||
url: "/api/organization/".concat(organizationId, "/execution"), | ||
headers: { | ||
'content-type': 'application/zip', | ||
connection: 'close', | ||
host: "localhost:".concat(port), | ||
}, | ||
}, | ||
{ | ||
url: "/api/organization/".concat(organizationId, "/execution"), | ||
headers: { | ||
'content-type': 'application/zip', | ||
connection: 'close', | ||
host: "localhost:".concat(port), | ||
}, | ||
}, | ||
]; | ||
sortedServerRequests = serverRequests | ||
.sort(sortByContentType) | ||
.map(function (req) { | ||
var headers = JSON.parse(JSON.stringify(req.headers)); | ||
delete headers['content-length']; | ||
return { | ||
url: req.url, | ||
headers: headers, | ||
}; | ||
}); | ||
sortedExpectedServerRequests = expectedServerRequests.sort(sortByContentType); | ||
assert_1.default.deepStrictEqual(sortedServerRequests, sortedExpectedServerRequests); | ||
expectedResponseBodies = [ | ||
{ | ||
hello: 'world', | ||
}, | ||
{ | ||
hello: 'world', | ||
}, | ||
]; | ||
assert_1.default.deepStrictEqual(responseBodies, expectedResponseBodies); | ||
return [2 /*return*/]; | ||
} | ||
}); | ||
}); }); | ||
}); | ||
// Requests are sent in parallel, so we don't know what request hit the server first. | ||
function sortByContentType(a, b) { | ||
return a.headers['content-type'].localeCompare(b.headers['content-type']); | ||
} | ||
//# sourceMappingURL=publish.test.js.map |
@@ -19,3 +19,4 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
const baseUrl = core.getInput('url'); | ||
const responseBodies = yield publish(globs, organizationId, baseUrl, process.env, vercelAuthenticator(baseUrl, password)); | ||
const zip = core.getBooleanInput('zip'); | ||
const responseBodies = yield publish(globs, zip, organizationId, baseUrl, process.env, vercelAuthenticator(baseUrl, password)); | ||
return responseBodies.map((body) => new URL(`/organization/${organizationId}/executions/${body.testSetExecutionId}`, baseUrl).toString()); | ||
@@ -22,0 +23,0 @@ }); |
@@ -19,7 +19,8 @@ #!/usr/bin/env node | ||
program.option('-u, --url <url>', 'OneReport URL', 'https://one-report.vercel.app'); | ||
program.option('--no-zip', 'Do not zip non .zip files', false); | ||
function main() { | ||
return __awaiter(this, void 0, void 0, function* () { | ||
program.parse(process.argv); | ||
const { organizationId, password, reports: globs, url: baseUrl } = program.opts(); | ||
const responseBodies = yield publish(globs, organizationId, baseUrl, process.env, vercelAuthenticator(baseUrl, password)); | ||
const { organizationId, password, reports: globs, url: baseUrl, noZip } = program.opts(); | ||
const responseBodies = yield publish(globs, !noZip, organizationId, baseUrl, process.env, vercelAuthenticator(baseUrl, password)); | ||
return responseBodies.map((body) => new URL(`/organization/${organizationId}/executions/${body.testSetExecutionId}`, baseUrl).toString()); | ||
@@ -26,0 +27,0 @@ }); |
@@ -7,2 +7,3 @@ import { Env } from '@cucumber/ci-environment'; | ||
* @param globs a list of globs pointing to JUnit XML and Cucumber JSON files | ||
* @param zip if true, compress all non .zip files into a zip file before publishing | ||
* @param organizationId the Organization ID on OneReport | ||
@@ -14,3 +15,3 @@ * @param baseUrl the base URL of OneReport (e.g. https://one-report.vercel.app/) | ||
*/ | ||
export declare function publish<ResponseBody>(globs: readonly string[], organizationId: string, baseUrl: string, env: Env, authenticate: Authenticate): Promise<readonly ResponseBody[]>; | ||
export declare function publish<ResponseBody>(globs: readonly string[], zip: boolean, organizationId: string, baseUrl: string, env: Env, authenticate: Authenticate): Promise<readonly ResponseBody[]>; | ||
//# sourceMappingURL=publish.d.ts.map |
@@ -20,2 +20,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
import { readStream } from './readStream.js'; | ||
import { zipPaths } from './zipPaths.js'; | ||
const lstat = promisify(fs.lstat); | ||
@@ -33,2 +34,3 @@ const extensions = ['.xml', '.json', '.ndjson', '.zip']; | ||
* @param globs a list of globs pointing to JUnit XML and Cucumber JSON files | ||
* @param zip if true, compress all non .zip files into a zip file before publishing | ||
* @param organizationId the Organization ID on OneReport | ||
@@ -40,3 +42,3 @@ * @param baseUrl the base URL of OneReport (e.g. https://one-report.vercel.app/) | ||
*/ | ||
export function publish(globs, organizationId, baseUrl, env, authenticate) { | ||
export function publish(globs, zip, organizationId, baseUrl, env, authenticate) { | ||
return __awaiter(this, void 0, void 0, function* () { | ||
@@ -58,3 +60,4 @@ if (!Array.isArray(globs)) { | ||
} | ||
return Promise.all(paths.map((path) => publishFile(path, url, ciEnv, authHeaders))); | ||
const publishPaths = zip ? yield zipPaths(paths) : paths; | ||
return Promise.all(publishPaths.map((path) => publishFile(path, url, ciEnv, authHeaders))); | ||
}); | ||
@@ -61,0 +64,0 @@ } |
@@ -23,3 +23,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
const baseUrl = `https://one-report.vercel.app`; | ||
const responseBodies = yield publish(['test/fixtures/*.{xml,json}'], process.env.ONE_REPORT_TEST_ORGANIZATION_ID, baseUrl, process.env, vercelAuthenticator(baseUrl, process.env.ONE_REPORT_PASSWORD)); | ||
const responseBodies = yield publish(['test/fixtures/*.{xml,json}'], true, process.env.ONE_REPORT_TEST_ORGANIZATION_ID, baseUrl, process.env, vercelAuthenticator(baseUrl, process.env.ONE_REPORT_PASSWORD)); | ||
assert.strictEqual(responseBodies.length, 2); | ||
@@ -26,0 +26,0 @@ for (const responseBody of responseBodies) { |
@@ -56,3 +56,3 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { | ||
})); | ||
it('publishes files from glob', () => __awaiter(void 0, void 0, void 0, function* () { | ||
it('publishes files from glob without zipping', () => __awaiter(void 0, void 0, void 0, function* () { | ||
const organizationId = '32C46057-0AB6-44E8-8944-0246E0BEA96F'; | ||
@@ -66,3 +66,3 @@ const fakeEnv = { | ||
}; | ||
const responseBodies = yield publish(['test/fixtures/*.{xml,json,ndjson,zip}'], organizationId, `http://localhost:${port}`, fakeEnv, () => Promise.resolve({})); | ||
const responseBodies = yield publish(['test/fixtures/*.{xml,json,ndjson,zip}'], false, organizationId, `http://localhost:${port}`, fakeEnv, () => Promise.resolve({})); | ||
const expectedServerRequests = [ | ||
@@ -122,4 +122,2 @@ { | ||
]; | ||
// Requests are sent in parallel, so we don't know what request hit the server first. | ||
const sortByContentType = (a, b) => a.headers['content-type'].localeCompare(b.headers['content-type']); | ||
const sortedServerRequests = serverRequests.sort(sortByContentType); | ||
@@ -144,3 +142,51 @@ const sortedExpectedServerRequests = expectedServerRequests.sort(sortByContentType); | ||
})); | ||
it('publishes files from glob with zipping', () => __awaiter(void 0, void 0, void 0, function* () { | ||
const organizationId = '32C46057-0AB6-44E8-8944-0246E0BEA96F'; | ||
const fakeEnv = {}; | ||
const responseBodies = yield publish(['test/fixtures/*.{xml,json,ndjson,zip}'], true, organizationId, `http://localhost:${port}`, fakeEnv, () => Promise.resolve({})); | ||
const expectedServerRequests = [ | ||
{ | ||
url: `/api/organization/${organizationId}/execution`, | ||
headers: { | ||
'content-type': 'application/zip', | ||
connection: 'close', | ||
host: `localhost:${port}`, | ||
}, | ||
}, | ||
{ | ||
url: `/api/organization/${organizationId}/execution`, | ||
headers: { | ||
'content-type': 'application/zip', | ||
connection: 'close', | ||
host: `localhost:${port}`, | ||
}, | ||
}, | ||
]; | ||
const sortedServerRequests = serverRequests | ||
.sort(sortByContentType) | ||
.map((req) => { | ||
const headers = JSON.parse(JSON.stringify(req.headers)); | ||
delete headers['content-length']; | ||
return { | ||
url: req.url, | ||
headers, | ||
}; | ||
}); | ||
const sortedExpectedServerRequests = expectedServerRequests.sort(sortByContentType); | ||
assert.deepStrictEqual(sortedServerRequests, sortedExpectedServerRequests); | ||
const expectedResponseBodies = [ | ||
{ | ||
hello: 'world', | ||
}, | ||
{ | ||
hello: 'world', | ||
}, | ||
]; | ||
assert.deepStrictEqual(responseBodies, expectedResponseBodies); | ||
})); | ||
}); | ||
// Requests are sent in parallel, so we don't know what request hit the server first. | ||
function sortByContentType(a, b) { | ||
return a.headers['content-type'].localeCompare(b.headers['content-type']); | ||
} | ||
//# sourceMappingURL=publish.test.js.map |
{ | ||
"name": "@smartbear/one-report-publisher", | ||
"version": "0.0.14", | ||
"version": "0.1.0", | ||
"description": "Publish Test Results to SmartBear OneReport", | ||
@@ -57,7 +57,7 @@ "type": "module", | ||
"@types/mocha": "9.1.0", | ||
"@types/node": "17.0.12", | ||
"@types/node": "17.0.13", | ||
"@typescript-eslint/eslint-plugin": "5.10.1", | ||
"@typescript-eslint/parser": "5.10.1", | ||
"esbuild": "0.14.14", | ||
"eslint": "8.7.0", | ||
"eslint": "8.8.0", | ||
"eslint-config-prettier": "8.3.0", | ||
@@ -82,3 +82,5 @@ "eslint-plugin-import": "2.25.4", | ||
"@cucumber/ci-environment": "9.0.0", | ||
"commander": "8.3.0", | ||
"@types/adm-zip": "0.4.34", | ||
"adm-zip": "0.5.9", | ||
"commander": "9.0.0", | ||
"fast-glob": "3.2.11", | ||
@@ -85,0 +87,0 @@ "source-map-support": "0.5.21" |
@@ -14,3 +14,4 @@ [](https://github.com/SmartBear/one-report-publisher/actions/workflows/test.yaml) | ||
The tool will also send Git metadata to OneReport: | ||
If the publisher is executed on a [supported CI server](https://github.com/cucumber/ci-environment#supported-ci-servers), | ||
it will also send the following git metadata along with the test results: | ||
@@ -22,12 +23,39 @@ - Repository URL | ||
The Git metadata is detected from environment variables defined by the CI server. See [cucumber/ci-environment](https://github.com/cucumber/ci-environment#readme) | ||
for details about [supported CI servers](https://github.com/cucumber/ci-environment#supported-ci-servers). | ||
## GitHub Actions | ||
## Command Line | ||
Add a step _after_ all tests have run. The `if: ${{ always() }}` ensures results are published even if a previous test | ||
step failed. | ||
The command-line tool can be launched with the Node.js `npx` command: | ||
```yml | ||
- name: 'Publish to OneReport' | ||
if: ${{ always() }} | ||
uses: smartbear/one-report-publisher@v0.1.0 | ||
with: | ||
organization-id: F5222E06-BA05-4C82-949A-2FE537B6F59F | ||
password: ${{ secrets.ONE_REPORT_PASSWORD }} | ||
reports: ./reports/**/*.{xml,json,ndjson,zip} | ||
``` | ||
## CircleCI | ||
Add a step _after_ all tests have run. You have to make sure the command is running in a docker image that has Node.js | ||
installed (for example [cimg/node](https://circleci.com/developer/images/image/cimg/node)). | ||
```yml | ||
- run: | ||
name: Publish test results to OneReport | ||
command: | | ||
npx @smartbear/one-report-publisher@0.1.0 \ | ||
--organization-id F5222E06-BA05-4C82-949A-2FE537B6F59F \ | ||
--password ${ONE_REPORT_PASSWORD} \ | ||
--reports ./reports/**/*.{xml,json,ndjson,zip} | ||
``` | ||
npx @smartbear/one-report-publisher@v0.0.13 --help | ||
## Command Line Reference | ||
The command-line tool can be used in any CI pipeline that has the `npx` command available (it needs to have Node.js installed). | ||
``` | ||
npx @smartbear/one-report-publisher@v0.1.0 --help | ||
Usage: one-report-publisher [options] | ||
@@ -38,4 +66,5 @@ | ||
-p, --password <password> OneReport password | ||
-r, --reports <glob> Glob to the files to publish | ||
-r, --reports <glob...> Glob to the files to publish | ||
-u, --url <url> OneReport URL (default: "https://one-report.vercel.app") | ||
--no-zip Do not zip non .zip files | ||
-h, --help display help for command | ||
@@ -47,18 +76,6 @@ ``` | ||
``` | ||
npx @smartbear/one-report-publisher@0.0.13 --organization-id F5222E06-BA05-4C82-949A-2FE537B6F59F --password ${ONE_REPORT_PASSWORD} --reports "./reports/**/*.{xml,json,ndjson,zip}" | ||
npx @smartbear/one-report-publisher@0.1.0 \ | ||
--organization-id F5222E06-BA05-4C82-949A-2FE537B6F59F \ | ||
--password ${ONE_REPORT_PASSWORD} \ | ||
--reports ./reports/**/*.{xml,json,ndjson,zip} | ||
``` | ||
The command-line tool can be used in any CI pipeline that has the `npx` command available | ||
## GitHub Actions | ||
The GitHub Action can be used as follows: | ||
```yml | ||
- name: 'Publish to OneReport' | ||
uses: smartbear/one-report-publisher@v0.0.13 | ||
with: | ||
organization-id: F5222E06-BA05-4C82-949A-2FE537B6F59F | ||
password: ${{ secrets.ONE_REPORT_PASSWORD }} | ||
reports: ./reports/**/*.{xml,json,ndjson,zip} | ||
``` |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
212915
19.69%119
15.53%2172
27.17%78
27.87%6
50%28
16.67%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
Updated