",
22 | "license": "MIT",
23 | "main": "xunit-viewer.js",
24 | "bin": {
25 | "xunit-viewer": "bin/xunit-viewer.js"
26 | },
27 | "directories": {
28 | "bin": "bin",
29 | "cli": "src/cli"
30 | },
31 | "scripts": {
32 | "start": "react-scripts start",
33 | "build": "react-scripts build",
34 | "test": "react-scripts test --reporters=default --reporters=jest-junit",
35 | "update": "node src/cli/update-expected.js",
36 | "eject": "react-scripts eject",
37 | "lint": "eslint xunit-viewer.js src --ignore-pattern src/cli/static/js/**/*",
38 | "test:ci": "echo blah",
39 | "test:document": "./bin/xunit-viewer.js -r junit.xml -o gh-pages/xunit-viewer-results.html",
40 | "test:document:serve": "npm run test:document -- -s",
41 | "demo": "./bin/xunit-viewer.js -r data -o gh-pages/index.html",
42 | "release:demo": "npm run test:document && npm run demo && gh-pages -d gh-pages",
43 | "deploy": "rm -rf src/cli/static && cp -r build/static src/cli/static",
44 | "build:cli": "npm run build && npm run deploy",
45 | "release": "./release.sh"
46 | },
47 | "dependencies": {
48 | "@uiw/react-codemirror": "^4.21.3",
49 | "chalk": "^5.2.0",
50 | "chokidar": "^3.5.3",
51 | "console-clear": "^1.1.1",
52 | "debounce": "^1.2.1",
53 | "detect-file-encoding-and-language": "^2.4.0",
54 | "express": "^4.18.2",
55 | "get-port": "^7.0.0",
56 | "handlebars": "^4.7.7",
57 | "ip": "^1.1.8",
58 | "lzutf8": "^0.6.3",
59 | "merge": "^2.1.1",
60 | "socket.io": "^4.6.2",
61 | "xml2js": "^0.6.0",
62 | "yargs": "^17.7.2"
63 | },
64 | "devDependencies": {
65 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
66 | "@fortawesome/fontawesome-free": "^6.4.0",
67 | "bulma": "^0.9.4",
68 | "eslint": "^8.43.0",
69 | "eslint-config-standard": "^17.1.0",
70 | "eslint-config-standard-react": "^13.0.0",
71 | "eslint-import-resolver-node": "^0.3.7",
72 | "eslint-plugin-import": "^2.27.5",
73 | "eslint-plugin-node": "^11.1.0",
74 | "eslint-plugin-promise": "^6.1.1",
75 | "eslint-plugin-react": "^7.32.2",
76 | "eslint-plugin-standard": "^5.0.0",
77 | "fuzzy": "^0.1.3",
78 | "gh-pages": "^5.0.0",
79 | "jest-junit": "^16.0.0",
80 | "linkify-html": "^4.1.1",
81 | "linkifyjs": "^4.1.1",
82 | "localforage": "^1.10.0",
83 | "match-sorter": "^6.3.1",
84 | "query-string": "^8.1.0",
85 | "react": "^18.2.0",
86 | "react-dom": "^18.2.0",
87 | "react-linkify": "^1.0.0-alpha",
88 | "react-render-if-visible": "^2.1.1",
89 | "react-router-dom": "^6.13.0",
90 | "react-scripts": "^5.0.1",
91 | "react-test-renderer": "^18.2.0",
92 | "sort-by": "^1.2.0",
93 | "stream": "^0.0.2",
94 | "timers": "^0.1.1"
95 | },
96 | "browserslist": [
97 | ">0.2%",
98 | "not dead",
99 | "not ie <= 11",
100 | "not op_mini all"
101 | ],
102 | "eslintIgnore": [
103 | "cli/static/**/*"
104 | ],
105 | "eslintConfig": {
106 | "extends": [
107 | "standard",
108 | "standard-react",
109 | "plugin:react/recommended"
110 | ],
111 | "env": {
112 | "browser": true,
113 | "jest": true,
114 | "jasmine": true
115 | },
116 | "rules": {
117 | "react/prop-types": 0,
118 | "react/jsx-closing-tag-location": 0,
119 | "react/jsx-closing-bracket-location": 0
120 | }
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lukejpreston/xunit-viewer/f41d75ff886ceaacb0c09e1d1feb088b1ebc23de/public/favicon.ico
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lukejpreston/xunit-viewer/f41d75ff886ceaacb0c09e1d1feb088b1ebc23de/public/icon.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Xunit Viewer
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | npm run lint
6 | npm run build:cli
7 | npm run test:ci
8 | ./bin/xunit-viewer.js -r junit.xml -c -C false
9 | CURRENT=$(echo $(npm version | grep xunit-viewer | cut -d"'" -f4))
10 | git commit --allow-empty -am "tested $CURRENT"
11 |
12 | npm version ${1-patch}
13 | npm publish
14 | LATEST=$(echo npm version | grep xunit-viewer | cut -d"'" -f4)
15 |
16 | npm run release:demo
17 |
18 | git add -A
19 | git commit --allow-empty -am "release demo $LATEST"
20 |
21 | git push
22 | git push --tags
23 |
--------------------------------------------------------------------------------
/sample-usage.js:
--------------------------------------------------------------------------------
1 | import xunitViewer from './xunit-viewer'
2 |
3 | xunitViewer({
4 | server: false,
5 | results: 'data',
6 | ignore: ['_thingy', 'invalid'],
7 | title: 'Xunit View Sample Tests',
8 | output: 'output.html'
9 | })
10 |
--------------------------------------------------------------------------------
/src/app/__snapshots__/hero.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders hero 1`] = `
4 |
7 |
10 |
13 |
16 |
27 |
28 |
31 |
34 |
![]()
37 |
40 |
41 |
42 |
43 |
44 |
45 | `;
46 |
47 | exports[`renders hero with title and brand 1`] = `
48 |
51 |
54 |
57 |
60 |
71 |
72 |
75 |
78 |

83 |
86 | bacon
87 |
88 |
89 |
90 |
91 |
92 |
93 | `;
94 |
--------------------------------------------------------------------------------
/src/app/__snapshots__/logo.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders logo 1`] = `
4 |
38 | `;
39 |
--------------------------------------------------------------------------------
/src/app/__snapshots__/properties-options.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders properties options 1`] = `
4 |
7 |
10 |
24 |
51 |
52 |
55 |
56 | `;
57 |
--------------------------------------------------------------------------------
/src/app/__snapshots__/suite-options.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders suite options 1`] = `
4 |
7 |
10 |
24 |
51 |
52 |
55 |
56 | `;
57 |
--------------------------------------------------------------------------------
/src/app/__snapshots__/test-options.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders test options 1`] = `
4 |
7 |
10 |
24 |
70 |
71 |
74 |
75 | `;
76 |
--------------------------------------------------------------------------------
/src/app/__snapshots__/toggle.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders toggle 1`] = `
4 |
20 | `;
21 |
--------------------------------------------------------------------------------
/src/app/app.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useReducer } from 'react'
2 | // import Files from './files'
3 | import Error from './error.js'
4 | import Hero from './hero.js'
5 | import initialState from './initial-state.js'
6 | import Loading from './loading.js'
7 | import parseAll from './parse-all.js'
8 | import PropertiesOptions from './properties-options.js'
9 | import reducer from './reducer.js'
10 | import SuiteOptions from './suite-options.js'
11 | import Suite from './suite.js'
12 | import TestOptions from './test-options.js'
13 |
14 | const App = ({ files, title, brand }) => {
15 | const [state, dispatch] = useReducer(reducer, initialState)
16 |
17 | useEffect(() => {
18 | if (Object.keys(state.suites).length === 0) parseAll(dispatch, files)
19 | // eslint-disable-next-line react-hooks/exhaustive-deps
20 | }, [])
21 |
22 | useEffect(() => {
23 | window.onbeforeprint = () => {
24 | dispatch({ type: 'print-mode', payload: { printMode: true } })
25 | }
26 |
27 | window.onafterprint = () => {
28 | dispatch({ type: 'print-mode', payload: { printMode: false } })
29 | }
30 | }, [])
31 |
32 | let currentPropertiesCount = 0
33 | let propertiesTotal = 0
34 | Object.entries(state.currentSuites).forEach(([key, suite]) => {
35 | currentPropertiesCount += Object.keys(suite.properties).filter(key => key !== '_active' && key !== '_visible').length
36 | Object.values(suite.tests).forEach(test => {
37 | if (test.properties) currentPropertiesCount += Object.keys(test.properties).filter(key => key !== '_active' && key !== '_visible').length
38 | })
39 | })
40 | Object.entries(state.currentSuites).forEach(([key, suite]) => {
41 | propertiesTotal += Object.keys(suite.properties).filter(key => key !== '_active' && key !== '_visible').length
42 | Object.values(suite.tests).forEach(test => {
43 | if (test.properties) propertiesTotal += Object.keys(test.properties).filter(key => key !== '_active' && key !== '_visible').length
44 | })
45 | })
46 |
47 | const testCounts = {}
48 | let testCount = 0
49 | let testTotal = 0
50 | Object.entries(state.currentSuites).forEach(([key, suite]) => {
51 | Object.entries(suite.tests).forEach(([key, test]) => {
52 | const status = test.status || 'unknown'
53 | testCounts[status] = testCounts[status] || {}
54 | testCounts[status].count = testCounts[status].count || 0
55 | testCounts[status].total = testCounts[status].total || 0
56 |
57 | testCounts[status].count += 1
58 | testCounts[status].total += 1
59 |
60 | testTotal += 1
61 | testCount += 1
62 | })
63 | })
64 |
65 | const onUpdate = ({ files }) => {
66 | parseAll(dispatch, files, {})
67 | }
68 |
69 | window.sockets = window.sockets || null
70 | useEffect(() => {
71 | if (window.sockets === null && 'io' in window) {
72 | window.sockets = window.io()
73 | window.sockets.on('update', onUpdate)
74 | }
75 | })
76 |
77 | return
78 |
{ dispatch({ type: 'toggle-menu' }) }}
81 | title={title}
82 | brand={brand}
83 | printMode={state.printMode}
84 | burger={state.hero.burger}
85 | dropdown={state.hero.dropdown}
86 | dispatch={dispatch}
87 | suites={state.currentSuites}
88 | />
89 |
117 |
118 |
119 | {state.errors &&
}
120 | {state.errors === null && Object.values(state.currentSuites).length === 0 &&
}
121 | {Object.values(state.currentSuites).length > 0 &&
122 | {
123 | Object.values(state.currentSuites)
124 | .sort((left, right) => {
125 | if (left.name < right.name) return -1
126 | if (left.name > right.name) return 1
127 | return 0
128 | })
129 | .map(suite => (
130 |
137 | ))
138 | }
139 |
140 | }
141 |
142 |
143 |
144 | }
145 |
146 | export default App
147 |
--------------------------------------------------------------------------------
/src/app/error.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const issueUrl = 'https://github.com/lukejpreston/xunit-viewer/issues/new?assignees=&labels=&template=issue.md&title='
4 |
5 | const Error = ({ errors }) => {
6 | return (
7 |
8 |
Errors found
9 |
There was an error parsing your data.
10 |
11 | {errors.map(({ file, error }) => (
12 | -
13 | {file}
14 |
15 | {error}
16 |
17 | ))}
18 |
19 |
20 | If you think this is an issue with the library please raise it here GitHub Issue
21 |
22 |
23 | )
24 | }
25 |
26 | export default Error
27 |
--------------------------------------------------------------------------------
/src/app/files.js:
--------------------------------------------------------------------------------
1 | // import React from 'react'
2 | // import { UnControlled as CodeMirror } from 'react-codemirror2'
3 | // import 'codemirror/lib/codemirror.css'
4 | // import 'codemirror/mode/xml/xml'
5 |
6 | // const ToggleFiles = ({ onClick }) =>
17 |
18 | // const Files = ({ active = false, setActive, files = [] }) => {
19 | // return
20 | //
21 | // { setActive(!active) }} />
22 | //
23 | //
24 | //
72 |
73 | //
74 | // {active
75 | // ?
77 | //
78 | //
79 | // java.lang.RuntimeException: There was an error
80 | //
81 | // `}
82 | // options={{
83 | // mode: 'xml',
84 | // lineNumbers: true
85 | // }}
86 | // onChange={(cm, { text }, value) => {
87 |
88 | // }}
89 | // />
90 | // : null}
91 | //
92 |
93 | //
94 | // }
95 |
96 | // export default Files
97 |
98 | const Files = () => null
99 |
100 | export default Files
101 |
--------------------------------------------------------------------------------
/src/app/hero.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Logo from './logo.js'
3 | import useVisibility from './visible.js'
4 |
5 | const FilterIcon = () =>
6 | const GearIcon = () =>
7 | const BoltLightningIcon = () =>
8 | const PrintIcon = () =>
9 | const HeartIcon = () =>
10 | const HeartCheckIcon = () =>
11 | const HeartExclamationIcon = () =>
12 | const PassedIcon = () =>
13 | const FailureIcon = () =>
14 | const ErrorIcon = () =>
15 | const SkippedIcon = () =>
16 | const UnknownIcon = () =>
17 | const CloseIcon = () =>
18 |
19 | const FastFilter = ({ dropdown, dispatch }) => {
20 | const { fastFilter, setSearchParams } = useVisibility()
21 | return (
22 |
23 |
24 |
38 |
39 |
40 |
41 |
{
44 | setSearchParams({
45 | passed: false,
46 | skipped: false,
47 | error: true,
48 | failure: true,
49 | unknown: true
50 | })
51 | }}
52 | >
53 | ERROR/FAILURE/UNKNOWN
54 |
55 |
{
58 | setSearchParams({
59 | passed: true,
60 | skipped: true,
61 | error: false,
62 | failure: false,
63 | unknown: false
64 | })
65 | }}
66 | >
67 | PASSED/SKIPPED
68 |
69 |
{
72 | setSearchParams({
73 | passed: true,
74 | skipped: true,
75 | error: true,
76 | failure: true,
77 | unknown: true
78 | })
79 | }}
80 | >
81 | ALL
82 |
83 |
{ }}
86 | >
87 | FILTERED
88 |
89 |
90 |
91 |
92 | )
93 | }
94 |
95 | const Hero = ({
96 | active,
97 | onFilterClick,
98 | title,
99 | brand,
100 | printMode,
101 | burger,
102 | dropdown,
103 | dispatch,
104 | suites
105 | }) => {
106 | const totalSuites = Object.values(suites).length
107 | const visibleSuiteCount = Object.values(suites).filter(({ _visible }) => _visible).length
108 |
109 | let propertiesTotal = 0
110 | let visiblePropertyCount = 0
111 | Object.entries(suites).forEach(([key, suite]) => {
112 | const found = Object.keys(suite.properties).filter(key => key !== '_active' && key !== '_visible').length
113 | propertiesTotal += found
114 | if (suite._visible) visiblePropertyCount += found
115 | Object.values(suite.tests).forEach(test => {
116 | if (test.properties) {
117 | const found = Object.keys(test.properties).filter(key => key !== '_active' && key !== '_visible').length
118 | propertiesTotal += found
119 | if (test.visible) visiblePropertyCount += found
120 | }
121 | })
122 | })
123 |
124 | const testCounts = {
125 | total: { passed: 0, error: 0, failure: 0, skipped: 0, unknown: 0 },
126 | visible: { passed: 0, error: 0, failure: 0, skipped: 0, unknown: 0 }
127 | }
128 | Object.values(suites).forEach(({ tests }) => {
129 | Object.values(tests).forEach(test => {
130 | testCounts.total[test.status] += 1
131 | if (test.visible) {
132 | testCounts.visible[test.status] += 1
133 | }
134 | })
135 | })
136 |
137 | return
138 |
139 |
195 |
196 |
197 | }
198 |
199 | export default Hero
200 |
--------------------------------------------------------------------------------
/src/app/index.css:
--------------------------------------------------------------------------------
1 | body,
2 | .card {
3 | color: #2C3E50 !important;
4 | }
5 |
6 | .filter {
7 | transition: background-color 0.2s ease-in-out;
8 | background-color: #2C3E50 !important;
9 | border: none;
10 | }
11 |
12 | .filter:hover,
13 | .filter:active,
14 | .filter:focus {
15 | background-color: rgb(52, 78, 104) !important;
16 | }
17 |
18 | .card-header-icon.is-hidden-tablet {
19 | max-width: 48px;
20 | }
21 |
22 | .navbar-brand {
23 | align-items: center;
24 | }
25 |
26 | .navbar-burger {
27 | color: white;
28 | }
29 |
30 | .navbar-menu {
31 | background-color: transparent !important;
32 | }
33 |
34 | .navbar-end {
35 | align-items: center;
36 | }
37 |
38 | .navbar-item.dropdown {
39 | padding-left: 0 !important;
40 | }
41 |
42 | .dropdown-item.is-disabled {
43 | filter: grayscale(1);
44 | opacity: 0.5;
45 | pointer-events: none;
46 | }
47 |
48 | @media screen and (min-width: 1024px) {
49 | .dropdown-trigger {
50 | min-width: 280px;
51 | }
52 |
53 | .dropdown-trigger .filter {
54 | float: right;
55 | }
56 | }
57 |
58 | .navbar-item .table {
59 | background-color: transparent !important;
60 | color: white !important;
61 | }
62 |
63 | .hero {
64 | background-color: #2C3E50 !important;
65 | padding: 10px !important;
66 | }
67 |
68 | .hero .container {
69 | width: 100%;
70 | }
71 |
72 | .Burger {
73 | height: 100%;
74 | }
75 |
76 | .hero-center {
77 | display: flex;
78 | justify-content: center;
79 | align-items: center;
80 | }
81 |
82 | .hero .brand,
83 | .hero .logo {
84 | height: 64px;
85 | width: auto;
86 | margin-right: 16px;
87 | }
88 |
89 | .hero .title {
90 | font-size: 36px;
91 | font-weight: normal;
92 | }
93 |
94 | .card-content {
95 | overflow-x: auto;
96 | overflow-y: hidden;
97 | }
98 |
99 | .card-header-icon {
100 | flex-grow: 1;
101 | background-color: transparent;
102 | border: none;
103 | font-size: 16px;
104 | color: #2C3E50;
105 | }
106 |
107 | .card-header-icon .icon {
108 | margin-left: auto;
109 | transition: transform 0.2s ease-in-out;
110 | transform: rotate(180deg);
111 | }
112 |
113 | .options:first-child {
114 | margin-top: 0.1rem;
115 | }
116 |
117 | .options.is-active .card-header-icon .icon,
118 | .files.is-active .card-header-icon .icon {
119 | transform: rotate(0deg);
120 | }
121 |
122 | .options-inputs {
123 | display: flex;
124 | align-items: center;
125 | overflow-x: auto;
126 | overflow-y: hidden;
127 | }
128 |
129 | .options-search {
130 | margin-bottom: 0 !important;
131 | margin-right: 32px;
132 | margin-left: 16px;
133 | min-width: 100px;
134 | }
135 |
136 | .input {
137 | border: none;
138 | border-radius: 0;
139 | box-shadow: none;
140 | border-bottom: 1px solid #2C3E50;
141 | padding: 0;
142 | }
143 |
144 | .options-total,
145 | .options-count {
146 | margin-right: 24px;
147 | font-size: 16px;
148 | }
149 |
150 | .options-count {
151 | display: flex;
152 | }
153 |
154 | .options-count .icon {
155 | font-size: 12px;
156 | }
157 |
158 | .options-toggles .toggle {
159 | justify-content: left;
160 | margin-right: 1em;
161 | }
162 |
163 | .options,
164 | .files {
165 | box-shadow: none;
166 | border-bottom: solid 1px #E7EAED;
167 | }
168 |
169 | .options .card-header,
170 | .files .card-header {
171 | box-shadow: none;
172 | }
173 |
174 | .options .card-header {
175 | padding: 10px 0;
176 | }
177 |
178 | .options .card-header-icon {
179 | padding: 0;
180 | }
181 |
182 | .options .card-header-icon .icon:last-child {
183 | margin-left: auto !important;
184 | margin-right: 14px !important;
185 | }
186 |
187 | .options-total .icon {
188 | font-size: 12px;
189 | transform: rotate(0);
190 | margin-right: 4px !important;
191 | }
192 |
193 | .options .card-content {
194 | transition: height 0.2s ease-in-out, padding 0.2s ease-in-out;
195 | height: 0;
196 | padding: 0 1.5rem;
197 | overflow: hidden;
198 | }
199 |
200 | .options.is-active .card-content {
201 | padding: 1.5rem;
202 | height: auto;
203 | }
204 |
205 | .properties-options-toggle {
206 | margin-right: 1rem;
207 | }
208 |
209 |
210 | .test-options-toggle-row {
211 | display: flex;
212 | }
213 |
214 | .properties-options-toggle-label,
215 | .test-options-toggle-row-label {
216 | color: #5e5e5e;
217 | display: inline-block;
218 | }
219 |
220 | .test-options-toggle-row-label {
221 | width: 100px;
222 | }
223 |
224 | .properties-options-toggle-label {
225 | width: 60px;
226 | }
227 |
228 | .test-options-toggle-row-label .icon {
229 | font-size: 14px;
230 | transform: translateY(1px);
231 | }
232 |
233 | .files .card-content {
234 | transition: height 0.2s ease-in-out, padding 0.2s ease-in-out;
235 | }
236 |
237 | .files .card-content {
238 | height: 0;
239 | padding: 0 1.5rem;
240 | }
241 |
242 | .files.is-active .card-content {
243 | height: 395px;
244 | }
245 |
246 | .toggles-container {
247 | display: flex;
248 | }
249 |
250 | .toggles {
251 | margin-right: 16px;
252 | width: 120px;
253 | }
254 |
255 | .toggles .title {
256 | margin-bottom: 4px;
257 | display: flex;
258 | align-items: center;
259 | }
260 |
261 | .toggles .title .icon {
262 | font-size: 14px;
263 | height: 100%;
264 | margin-left: -6px;
265 | }
266 |
267 | .toggle {
268 | border: none;
269 | margin-bottom: 4px;
270 | padding: 0;
271 | height: auto;
272 | }
273 |
274 | .toggle-rail {
275 | position: relative;
276 | margin-right: 2px;
277 | background-color: #2C3E50;
278 | width: 20px;
279 | height: 8px;
280 | border-radius: 4px;
281 | margin-right: 8px;
282 | }
283 |
284 | .toggle-handle {
285 | transition: left 0.1s ease-in-out;
286 | position: absolute;
287 | top: -3px;
288 | height: 14px;
289 | width: 14px;
290 | border-radius: 90px;
291 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.25);
292 | left: -1px;
293 | background-color: #F5F9FC;
294 | }
295 |
296 | .toggle.is-active .toggle-handle {
297 | background-color: #375C80;
298 | left: 8px;
299 | }
300 |
301 | .files .card-content {
302 | padding: 0;
303 | }
304 |
305 | .tabs {
306 | margin: 0 !important;
307 | margin-top: 1px !important;
308 | }
309 |
310 | .files li a {
311 | border-radius: 0 !important;
312 | }
313 |
314 | .files li a .delete {
315 | margin-left: 6px;
316 | }
317 |
318 | .files-input {
319 | margin-left: 16px;
320 | margin-bottom: 16px;
321 | width: 98%;
322 | }
323 |
324 | .add-file {
325 | padding: 0.5em 0 !important;
326 | }
327 |
328 | .files .subtitle {
329 | padding-left: 4px;
330 | margin-bottom: 0 !important;
331 | }
332 |
333 | .files .subtitle .icon {
334 | transform: rotate(0) translateY(2px) !important;
335 | font-size: 18px;
336 | margin-right: 4px;
337 | }
338 |
339 | .suite {
340 | transition: box-shadow 0.1s ease-in-out, margin 0.1s ease-in-out;
341 | background-color: #F7F7F7;
342 | margin: 12px 0;
343 | }
344 |
345 | .suite .card-header {
346 | transition: background-color 0.2s ease-in-out;
347 | border: none;
348 | width: 100%;
349 | display: flex;
350 | flex-wrap: wrap;
351 | }
352 |
353 | .suite-count-container {
354 | width: 100%;
355 | text-align: left;
356 | }
357 |
358 | .suite .card-header:hover {
359 | cursor: pointer;
360 | }
361 |
362 | .suite .card-header:hover,
363 | .suite .card-header:active,
364 | .suite .card-header:focus {
365 | background-color: #ebebeb;
366 | }
367 |
368 | .suite.is-empty .card-header {
369 | background-color: #F7F7F7;
370 | }
371 |
372 | .suite.is-empty .card-header:hover {
373 | cursor: initial;
374 | }
375 |
376 |
377 | .suite.is-active {
378 | margin: 24px 0;
379 | }
380 |
381 | .suite.is-inactive,
382 | .suite.is-empty {
383 | box-shadow: none;
384 | margin: 8px 0;
385 | }
386 |
387 | .suites {
388 | margin-top: 32px !important;
389 | }
390 |
391 | .suite .card {
392 | margin-bottom: 16px;
393 | }
394 |
395 | .suite .card:last-child {
396 | margin-bottom: 0;
397 | }
398 |
399 | .properties .card-header-icon svg,
400 | .suite .card-header-icon svg {
401 | transition: transform 0.2s ease-in-out;
402 | }
403 |
404 | .properties.is-active .card-header-icon svg,
405 | .suite.is-active .card-header-icon svg {
406 | transform: rotate(180deg);
407 | }
408 |
409 | .suite .card-header-title,
410 | .suite .card-header-icon {
411 | padding: 0;
412 | }
413 |
414 | .suite .card-header-title {
415 | margin-left: 16px;
416 | }
417 |
418 | .suite .card-header-icon {
419 | margin-right: 16px;
420 | }
421 |
422 | .suite .card-header {
423 | padding: 0.75rem;
424 | }
425 |
426 | .card-header-title small {
427 | font-weight: normal;
428 | margin-left: 8px;
429 | }
430 |
431 | .test .card-header-title,
432 | .properties .card-header-title {
433 | margin-bottom: 0 !important;
434 | }
435 |
436 | .properties .card-content {
437 | padding: 0;
438 | padding-top: 1px;
439 | }
440 |
441 | .test>.card-header>.card-header-title {
442 | color: #fff;
443 | }
444 |
445 | .test>.card-header>.card-header-icon {
446 | color: #fff;
447 | }
448 |
449 | .test.is-empty>.card-header,
450 | .test>.card-header {
451 | background-color: #4B85FA;
452 | }
453 |
454 | .test.is-populated>.card-header:hover,
455 | .test.is-populated>.card-header:active,
456 | .test.is-populated>.card-header:focus {
457 | background-color: #2f5bb4;
458 | }
459 |
460 | .test.is-passed>.card-header {
461 | background-color: #0DBF1F;
462 | }
463 |
464 | .test.is-populated.is-passed>.card-header:hover,
465 | .test.is-populated.is-passed>.card-header:active,
466 | .test.is-populated.is-passed>.card-header:focus {
467 | background-color: #25a132;
468 | }
469 |
470 | .test.is-failure>.card-header {
471 | background-color: #B32010;
472 | }
473 |
474 | .test.test.is-populated.is-failure>.card-header:hover,
475 | .test.test.is-populated.is-failure>.card-header:active,
476 | .test.test.is-populated.is-failure>.card-header:focus {
477 | background-color: #963126;
478 | }
479 |
480 | .test.is-error>.card-header {
481 | background-color: #E5CB05;
482 | }
483 |
484 |
485 | .test.is-populated.is-error>.card-header:hover,
486 | .test.is-populated.is-error>.card-header:active,
487 | .test.is-populated.is-error>.card-header:focus {
488 | background-color: #c7b319;
489 | }
490 |
491 | .test.is-skipped>.card-header {
492 | background-color: #adb2b6;
493 | }
494 |
495 | .test.is-populated.is-skipped>.card-header:hover,
496 | .test.is-populated.is-skipped>.card-header:active,
497 | .test.is-populated.is-skipped>.card-header:focus {
498 | background-color: #828588;
499 | }
500 |
501 | .test.is-active .fa-angle-down {
502 | transform: rotate(180deg);
503 | }
504 |
505 |
506 | .suite.is-empty,
507 | .suite.is-empty .card-header .test.is-empty,
508 | .test.is-empty .card-header {
509 | box-shadow: none;
510 | opacity: 0.7;
511 | cursor: initial;
512 | }
513 |
514 | .card-header {
515 | user-select: text;
516 | }
517 |
518 | .test.card {
519 | transition: margin 0.1s ease-in-out;
520 | margin: 8px 0;
521 | }
522 |
523 | .test.card.is-populated.is-active {
524 | margin: 24px 0;
525 | }
526 |
527 | .suite-count {
528 | font-size: 12px;
529 | margin-left: 12px;
530 | }
531 |
532 | .suite-count .icon {
533 | font-size: 10px;
534 | margin-right: -4px;
535 | }
536 |
537 | .raw-content {
538 | margin: 2px;
539 | }
540 |
541 | .pretty-content {
542 | margin: 20px;
543 | }
544 |
545 | .card,
546 | .card-header {
547 | box-shadow: none !important;
548 | }
549 |
550 | .card .card {
551 | border: solid 1px rgba(10, 10, 10, 0.1) !important;
552 | }
553 |
554 | .card-header {
555 | border-bottom: solid 1px #ccc !important;
556 | }
557 |
558 | .card.is-inactive .card-header {
559 | border: none !important;
560 | border-radius: 4px;
561 | }
562 |
563 | .options {
564 | border-radius: 0;
565 | }
566 |
567 | .options:last-child {
568 | border-radius: 4px;
569 | border-bottom: none;
570 | }
571 |
572 | @media (prefers-color-scheme: dark) {
573 | body,
574 | html {
575 | background-color: #051221;
576 | }
577 |
578 | .card {
579 | background-color: #2C3E50;
580 | color: white !important;
581 | /* box-shadow: 0 0.5em 1em -0.125em rgba(255, 255, 255, 0.1), 0 0px 0 1px rgba(255, 255, 255, 0.02) !important; */
582 | }
583 |
584 | .card .card {
585 | border-color: rgba(255, 255, 255, 0.1) !important;
586 | }
587 |
588 | .card-header svg path {
589 | color: white !important;
590 | fill: white !important;
591 | }
592 |
593 | .card-header {
594 | color: white !important;
595 | border-color: white !important;
596 | /* box-shadow: 0 0.125em 0.25em rgba(255, 255, 255, 0.1); */
597 | }
598 |
599 | .card-header-title {
600 | color: white !important;
601 | }
602 |
603 | .card-header:hover,
604 | .card-header:active,
605 | .card-header:focus {
606 | background-color: #17202a !important;
607 | }
608 |
609 | .table {
610 | background-color: #4d6784;
611 | color: white;
612 | }
613 |
614 | th {
615 | color: white !important;
616 | }
617 |
618 | .toggle {
619 | background-color: transparent;
620 | color: white !important;
621 | }
622 |
623 | .toggle-rail {
624 | background-color: #17202a;
625 | }
626 |
627 | .raw-content pre {
628 | background-color: #17202a;
629 | color: white !important;
630 | }
631 |
632 | .dropdown-content,
633 | .dropdown-item {
634 | background-color: #17202a;
635 | color: white;
636 | }
637 |
638 | .dropdown-item:hover,
639 | .dropdown-item:active,
640 | .dropdown-item:focus {
641 | background-color: #030d17 !important;
642 | color: white !important;
643 | }
644 |
645 | .input {
646 | background-color: transparent !important;
647 | color: white !important;
648 | border-color: white !important;
649 | }
650 |
651 | .input::placeholder {
652 | color: rgb(136, 136, 136) !important;
653 | }
654 |
655 | .options-inputs {
656 | color: white !important;
657 | }
658 |
659 | .test-options-toggle-row-label,
660 | .properties-options-toggle-label {
661 | color: white !important;
662 | }
663 |
664 | .test.is-empty>.card-header,
665 | .test>.card-header {
666 | background-color: #4B85FA !important;
667 | }
668 |
669 | .test.is-populated>.card-header:hover,
670 | .test.is-populated>.card-header:active,
671 | .test.is-populated>.card-header:focus {
672 | background-color: #2f5bb4 !important;
673 | }
674 |
675 | .test.is-passed>.card-header {
676 | background-color: #0DBF1F !important;
677 | }
678 |
679 | .test.is-populated.is-passed>.card-header:hover,
680 | .test.is-populated.is-passed>.card-header:active,
681 | .test.is-populated.is-passed>.card-header:focus {
682 | background-color: #25a132 !important;
683 | }
684 |
685 | .test.is-failure>.card-header {
686 | background-color: #B32010 !important;
687 | }
688 |
689 | .test.test.is-populated.is-failure>.card-header:hover,
690 | .test.test.is-populated.is-failure>.card-header:active,
691 | .test.test.is-populated.is-failure>.card-header:focus {
692 | background-color: #963126 !important;
693 | }
694 |
695 | .test.is-error>.card-header {
696 | background-color: #E5CB05 !important;
697 | }
698 |
699 |
700 | .test.is-populated.is-error>.card-header:hover,
701 | .test.is-populated.is-error>.card-header:active,
702 | .test.is-populated.is-error>.card-header:focus {
703 | background-color: #c7b319 !important;
704 | }
705 |
706 | .test.is-skipped>.card-header {
707 | background-color: #adb2b6 !important;
708 | }
709 |
710 | .test.is-populated.is-skipped>.card-header:hover,
711 | .test.is-populated.is-skipped>.card-header:active,
712 | .test.is-populated.is-skipped>.card-header:focus {
713 | background-color: #828588 !important;
714 | }
715 |
716 | .options,
717 | .options .card-header {
718 | background-color: transparent !important;
719 | }
720 | }
--------------------------------------------------------------------------------
/src/app/initial-state.js:
--------------------------------------------------------------------------------
1 | export default {
2 | hero: {
3 | burger: false,
4 | dropdown: false,
5 | fastFilter: 0
6 | },
7 | printMode: false,
8 | error: null,
9 | suites: {},
10 | currentSuites: {},
11 | menuActive: false,
12 | suiteOptionsActive: false,
13 | testOptionsActive: false,
14 | propertiesOptionsActive: false,
15 | activeFiles: false,
16 | suitesExpanded: true,
17 | suitesEmpty: true,
18 | propertiesExpanded: {
19 | all: true,
20 | suites: true,
21 | tests: true
22 | },
23 | propertiesVisible: {
24 | all: true,
25 | suites: true,
26 | tests: true
27 | },
28 | testToggles: {
29 | all: {
30 | expanded: true,
31 | raw: true
32 | },
33 | passed: {
34 | expanded: true,
35 | raw: true
36 | },
37 | skipped: {
38 | expanded: true,
39 | raw: true
40 | },
41 | failure: {
42 | expanded: true,
43 | raw: true
44 | },
45 | error: {
46 | expanded: true,
47 | raw: true
48 | },
49 | unknown: {
50 | expanded: true,
51 | raw: true
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/app/loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Loading = () => (
4 |
5 |
6 |
Parsing the files
7 |
8 | )
9 |
10 | export default Loading
11 |
--------------------------------------------------------------------------------
/src/app/logo.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Logo = () =>
10 |
11 | export default Logo
12 |
--------------------------------------------------------------------------------
/src/app/logo.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Logo from './logo'
3 | import renderer from 'react-test-renderer'
4 |
5 | test('renders logo', () => {
6 | const tree = renderer.create().toJSON()
7 | expect(tree).toMatchSnapshot()
8 | })
9 |
--------------------------------------------------------------------------------
/src/app/parse-all.js:
--------------------------------------------------------------------------------
1 | import merge from 'merge'
2 | import parse from './parse.js'
3 |
4 | export default async (dispatch, files, suites, filters = { passed: false, skipped: false, unknown: true, failure: true, error: true }) => {
5 | for (const { file, contents } of files) {
6 | try {
7 | const parsed = await parse(contents, filters)
8 | if (Object.keys(parsed.suites).length === 0) {
9 | dispatch({
10 | type: 'parse-error',
11 | payload: {
12 | file,
13 | error: 'No suites or tests detected in this file. It could be an unsupported format.'
14 | }
15 | })
16 | }
17 | suites = merge.recursive(true, suites, parsed)
18 | } catch (err) {
19 | dispatch({
20 | type: 'parse-error',
21 | payload: {
22 | file,
23 | error: err.message
24 | }
25 | })
26 | }
27 | }
28 |
29 | if ('suites' in suites && Object.keys(suites.suites).length > 0) {
30 | dispatch({
31 | type: 'parse-suites',
32 | payload: {
33 | suites: suites.suites
34 | }
35 | })
36 | } else {
37 | dispatch({
38 | type: 'parse-error',
39 | payload: {
40 | file: 'See errors',
41 | error: 'No suites or tests detected.'
42 | }
43 | })
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/parse.js:
--------------------------------------------------------------------------------
1 | import '../cli/parse.js'
2 | const parse = window.parse
3 | export default parse
4 |
--------------------------------------------------------------------------------
/src/app/properties-options.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Toggle from './toggle.js'
3 |
4 | const Search = ({ label, dispatch }) =>
5 |
6 | {
8 | dispatch({
9 | type: 'search-properties',
10 | payload: {
11 | value: evt.target.value
12 | }
13 | })
14 | }}
15 | className='input'
16 | type='text'
17 | placeholder={label} />
18 |
19 |
20 |
21 | const Total = ({ count, total }) =>
22 | {count}/{total}
23 |
24 |
25 | const EyeIcon = () =>
26 |
27 |
28 |
29 | const EyeSlashIcon = () =>
30 |
31 |
32 |
33 | const ChevronUpIcon = () =>
34 |
35 |
36 |
37 | const ChevronDownIcon = () =>
38 |
39 |
40 |
41 | const ToggleRow = ({ type, label, propertiesVisible, propertiesExpanded, dispatch }) =>
42 |
43 | {label}
44 |
45 |
{
49 | dispatch({
50 | type: 'toggle-properties-visbility',
51 | payload: {
52 | type,
53 | active: !propertiesVisible[type]
54 | }
55 | })
56 | }}
57 | onLabel='Visible'
58 | offLabel='Hidden'
59 | onIcon={}
60 | offIcon={} />
61 | {
63 | dispatch({
64 | type: 'toggle-all-properties',
65 | payload: {
66 | type,
67 | active: !propertiesExpanded[type]
68 | }
69 | })
70 | }}
71 | className='properties-options-toggle'
72 | active={propertiesExpanded[type]}
73 | onLabel='Expanded'
74 | offLabel='Contracted'
75 | offIcon={}
76 | onIcon={} />
77 |
78 |
79 | const PropertiesOptions = ({ count = 0, total = 0, active = false, dispatch, propertiesExpanded = { all: true, suites: true, tests: true }, propertiesVisible = { all: true, suites: true, tests: true } }) => {
80 | return
81 |
82 |
83 |
93 |
94 |
95 | {active
96 | ?
97 |
98 |
99 |
100 |
101 | : null}
102 |
103 |
104 |
105 | }
106 |
107 | export default PropertiesOptions
108 |
--------------------------------------------------------------------------------
/src/app/properties-options.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropertiesOptions from './properties-options'
3 | import renderer from 'react-test-renderer'
4 |
5 | test('renders properties options', () => {
6 | const tree = renderer.create().toJSON()
7 | expect(tree).toMatchSnapshot()
8 | })
9 |
--------------------------------------------------------------------------------
/src/app/reducer.js:
--------------------------------------------------------------------------------
1 | import fuzzy from 'fuzzy'
2 | import merge from 'merge'
3 |
4 | const toggleAllProperties = (state, payload, update, toggleType, suiteTesttoggleType) => {
5 | update[toggleType] = state[toggleType]
6 | update[toggleType][payload.type] = payload.active
7 |
8 | if (payload.type === 'all') {
9 | update[toggleType].suites = payload.active
10 | update[toggleType].tests = payload.active
11 | }
12 |
13 | if (payload.type === 'all' || payload.type === 'suites') {
14 | Object.values(update.currentSuites).forEach(suite => {
15 | suite.properties[suiteTesttoggleType] = payload.active
16 | })
17 | }
18 |
19 | if (payload.type === 'all' || payload.type === 'tests') {
20 | Object.values(update.currentSuites).forEach(suite => {
21 | Object.values(suite.tests).forEach(test => {
22 | if ('properties' in test) {
23 | test.properties[suiteTesttoggleType] = payload.active
24 | }
25 | })
26 | })
27 | }
28 | return update
29 | }
30 |
31 | export default (state, { type, payload }) => {
32 | let update = {}
33 | update.currentSuites = state.currentSuites
34 |
35 | if (type === 'parse-error') {
36 | state = merge.recursive(true, {}, state)
37 | state.errors = state.errors || []
38 | state.errors.push({
39 | error: payload.error,
40 | file: payload.file
41 | })
42 | }
43 |
44 | if (type === 'parse-suites') {
45 | state = merge.recursive(true, {}, state)
46 | state.suites = payload.suites
47 | state.currentSuites = payload.suites
48 | Object.values(state.currentSuites).forEach(suite => {
49 | if (Object.keys(suite.tests).length > 0 || Object.keys(suite.properties).length > 0) suite.active = true
50 | })
51 | }
52 |
53 | if (type === 'search-suites') {
54 | Object.values(state.suites).forEach(({ name, id }) => {
55 | if (fuzzy.test(payload.value.toLowerCase(), name.toLowerCase())) {
56 | update.currentSuites[id] = update.currentSuites[id] || merge.recursive(true, {}, state.suites[id])
57 | if (!('active' in update.currentSuites[id])) update.currentSuites[id].active = true
58 | } else delete update.currentSuites[id]
59 | })
60 | update.suitesExpanded = Object.values(update.currentSuites).some(suite => suite.active === true)
61 | }
62 | if (type === 'search-tests') {
63 | Object.values(state.suites).forEach(suite => {
64 | Object.values(suite.tests).forEach(test => {
65 | if (!fuzzy.test(payload.value.toLowerCase(), test.name.toLowerCase()) && !test.messages.some(message => fuzzy.test(payload.value.toLowerCase(), message.toLowerCase()))) {
66 | if (update.currentSuites[suite.id]) delete update.currentSuites[suite.id].tests[test.id]
67 | } else if (suite.id in update.currentSuites && !(test.id in update.currentSuites[suite.id].tests)) {
68 | if (update.currentSuites[suite.id]) {
69 | update.currentSuites[suite.id].tests[test.id] = merge.recursive(true, {}, state.suites[suite.id].tests[test.id])
70 | update.currentSuites[suite.id].tests[test.id].active = true
71 | update.currentSuites[suite.id].tests[test.id].visible = true
72 | update.currentSuites[suite.id].tests[test.id].raw = true
73 | }
74 | }
75 | })
76 | })
77 | }
78 | if (type === 'search-properties') {
79 | Object.values(state.suites).forEach(suite => {
80 | Object.entries(suite.properties)
81 | .filter(([key]) => key !== '_visible' && key !== '_active')
82 | .forEach(([key, values]) => {
83 | values = values || []
84 | if (!fuzzy.test(payload.value.toLowerCase(), key.toLowerCase()) && !values.some(value => fuzzy.test(payload.value.toLowerCase(), value.toLowerCase()))) delete update.currentSuites[suite.id].properties[key]
85 | else if (suite.id in update.currentSuites && !(key in update.currentSuites[suite.id].properties)) {
86 | if (update.currentSuites[suite.id]) {
87 | update.currentSuites[suite.id].properties[key] = [].concat(state.suites[suite.id].properties[key])
88 | update.currentSuites[suite.id].properties._active = true
89 | update.currentSuites[suite.id].properties._visible = true
90 | update.propertiesExpanded = false
91 | }
92 | }
93 | })
94 | })
95 | update.propertiesExpanded = Object.values(update.currentSuites).some((suite) => {
96 | return suite.properties._active || false
97 | })
98 | update.propertiesVisible = Object.values(update.currentSuites).some((suite) => {
99 | return suite.properties._visible || false
100 | })
101 | }
102 |
103 | if (type === 'toggle-all-suites') {
104 | update.suitesExpanded = !state.suitesExpanded
105 | Object.values(update.currentSuites).forEach(suite => { suite.active = update.suitesExpanded })
106 | }
107 | if (type === 'toggle-empty-suites') {
108 | update.suitesEmpty = !state.suitesEmpty
109 | }
110 | if (type === 'toggle-menu') update.menuActive = !state.menuActive
111 | if (type === 'toggle-suite-options') update.suiteOptionsActive = !state.suiteOptionsActive
112 | if (type === 'toggle-test-options') update.testOptionsActive = !state.testOptionsActive
113 | if (type === 'toggle-properties-options') update.propertiesOptionsActive = !state.propertiesOptionsActive
114 | if (type === 'toggle-files') update.activeFiles = !state.activeFiles
115 | if (type === 'toggle-suite') {
116 | update.currentSuites[payload.id].active = payload.active
117 | update.suitesExpanded = Object.values(update.currentSuites).some(suite => suite.active === true)
118 | }
119 |
120 | if (type === 'toggle-properties') {
121 | if (typeof payload.test !== 'undefined' && payload.test !== null) {
122 | update.currentSuites[payload.suite].tests[payload.test].properties._active = payload.active
123 | } else {
124 | update.currentSuites[payload.suite].properties._active = payload.active
125 | update.propertiesExpanded = Object.values(update.currentSuites).some((suite) => {
126 | return suite.properties._active || false
127 | })
128 | }
129 | }
130 |
131 | if (type === 'toggle-all-properties') {
132 | update = toggleAllProperties(state, payload, update, 'propertiesExpanded', '_active')
133 | }
134 | if (type === 'toggle-properties-visbility') {
135 | update = toggleAllProperties(state, payload, update, 'propertiesVisible', '_visible')
136 | }
137 |
138 | if (type === 'toggle-test') {
139 | update.currentSuites[payload.suite].tests[payload.id].active = payload.active
140 | }
141 | if (type === 'toggle-test-mode') {
142 | update.currentSuites[payload.suite].tests[payload.id].raw = payload.raw
143 | }
144 |
145 | if (type === 'toggle-test-expanded') {
146 | update.testToggles = state.testToggles
147 | update.testToggles[payload.status].expanded = payload.active
148 |
149 | Object.values(update.currentSuites).forEach(suite => {
150 | Object.values(suite.tests).forEach(test => {
151 | if (payload.status === 'all') test.active = payload.active
152 | else if (payload.status === test.status) test.active = payload.active
153 | else if (typeof test.status === 'undefined' && payload.status === 'unknown') test.active = payload.active
154 | })
155 | })
156 |
157 | if (payload.status === 'all') {
158 | update.testToggles.passed.expanded = payload.active
159 | update.testToggles.failure.expanded = payload.active
160 | update.testToggles.error.expanded = payload.active
161 | update.testToggles.skipped.expanded = payload.active
162 | update.testToggles.unknown.expanded = payload.active
163 | } else {
164 | if (update.testToggles.passed.expanded &&
165 | update.testToggles.failure.expanded &&
166 | update.testToggles.error.expanded &&
167 | update.testToggles.skipped.expanded &&
168 | update.testToggles.unknown.expanded) update.testToggles.all.expanded = true
169 | else update.testToggles.all.expanded = false
170 | }
171 | }
172 |
173 | if (type === 'toggle-test-raw') {
174 | update.testToggles = state.testToggles
175 | update.testToggles[payload.status].raw = payload.active
176 |
177 | Object.values(update.currentSuites).forEach(suite => {
178 | Object.values(suite.tests).forEach(test => {
179 | if (payload.status === 'all') test.raw = payload.active
180 | else if (payload.status === test.status) test.raw = payload.active
181 | else if (typeof test.status === 'undefined' && payload.status === 'unknown') test.raw = payload.active
182 | })
183 | })
184 |
185 | if (payload.status === 'all') {
186 | update.testToggles.passed.raw = payload.active
187 | update.testToggles.failure.raw = payload.active
188 | update.testToggles.error.raw = payload.active
189 | update.testToggles.skipped.raw = payload.active
190 | update.testToggles.unknown.raw = payload.active
191 | } else {
192 | if (update.testToggles.passed.raw &&
193 | update.testToggles.failure.raw &&
194 | update.testToggles.error.raw &&
195 | update.testToggles.skipped.raw &&
196 | update.testToggles.unknown.raw) update.testToggles.all.raw = true
197 | }
198 | }
199 |
200 | if (type === 'hero-burger') {
201 | const { burger } = state.hero
202 | update.hero = update.hero || {}
203 | update.hero.burger = !burger
204 | }
205 |
206 | if (type === 'hero-dropdown') {
207 | const { dropdown } = state.hero
208 | update.hero = update.hero || {}
209 | update.hero.dropdown = !dropdown
210 | }
211 |
212 | if (type === 'hero-print-mode') {
213 | const { printMode } = state
214 | update.printMode = !printMode
215 | }
216 |
217 | if (type === 'print-mode') {
218 | update.printMode = payload.printMode
219 | }
220 |
221 | state = merge.recursive(true, state, update)
222 |
223 | state = merge.recursive(true, state, update)
224 |
225 | Object.values(state.currentSuites).forEach(suite => {
226 | if (!state.suitesEmpty) suite._visible = true
227 | else suite._visible = (Object.keys(suite.tests).length > 0 && Object.values(suite.tests).filter(test => test.visible).length > 0) || (suite.properties._visible && Object.keys(suite.properties).filter(prop => prop !== '_visible').length > 0)
228 | })
229 |
230 | return state
231 | }
232 |
--------------------------------------------------------------------------------
/src/app/suite-count.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const icons = {
4 | passed: 'check',
5 | failure: 'times',
6 | error: 'exclamation',
7 | skipped: 'ban',
8 | unknown: 'question'
9 | }
10 |
11 | const SuiteCount = ({ count, type }) => count > 0
12 | ?
13 |
14 |
15 |
16 | {count}
17 |
18 | : null
19 |
20 | export default SuiteCount
21 |
--------------------------------------------------------------------------------
/src/app/suite-options.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Toggle from './toggle.js'
3 |
4 | const Search = ({ label, dispatch }) =>
5 |
6 | {
8 | dispatch({
9 | type: 'search-suites',
10 | payload: {
11 | value: evt.target.value
12 | }
13 | })
14 | }}
15 | className='input'
16 | type='text'
17 | placeholder={label} />
18 |
19 |
20 |
21 | const Total = ({ count, total }) =>
22 | {count}/{total}
23 |
24 |
25 | const ChevronUpIcon = () =>
26 |
27 |
28 |
29 | const ChevronDownIcon = () =>
30 |
31 |
32 |
33 | const HideIcon = () =>
34 |
35 |
36 |
37 | const ShowIcon = () =>
38 |
39 |
40 |
41 | const SuiteOptions = ({ suitesExpanded = true, suitesEmpty = true, count = 0, total = 0, dispatch, active = false }) => {
42 | return
43 |
44 |
45 |
55 |
56 |
57 | {active
58 | ? <>
59 | dispatch({ type: 'toggle-all-suites' })}
61 | active={suitesExpanded}
62 | onLabel='Expanded'
63 | offLabel='Contracted'
64 | offIcon={}
65 | onIcon={} />
66 | dispatch({ type: 'toggle-empty-suites' })}
68 | active={suitesEmpty}
69 | onLabel='Empty hidden'
70 | offLabel='Empty shown'
71 | onIcon={}
72 | offIcon={} />
73 | >
74 | : null}
75 |
76 |
77 |
78 | }
79 |
80 | export default SuiteOptions
81 |
--------------------------------------------------------------------------------
/src/app/suite-options.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import SuiteOptions from './suite-options'
3 | import renderer from 'react-test-renderer'
4 |
5 | test('renders suite options', () => {
6 | const tree = renderer.create().toJSON()
7 | expect(tree).toMatchSnapshot()
8 | })
9 |
--------------------------------------------------------------------------------
/src/app/suite.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import X from 'react-render-if-visible'
3 | import Toggle from './toggle.js'
4 | import SuiteCount from './suite-count.js'
5 | import Y from 'react-linkify'
6 | import linkify from 'linkify-html'
7 | import useVisibility from './visible.js'
8 | const Linkify = Y.default
9 |
10 | const RenderIfVisible = X.default
11 | const RenderAlways = (props) =>
12 |
13 | const icons = {
14 | passed: 'check',
15 | failure: 'times',
16 | error: 'exclamation',
17 | skipped: 'ban',
18 | unknown: 'question'
19 | }
20 |
21 | const statusRank = [
22 | 'failure',
23 | 'error',
24 | 'passed',
25 | 'skipped',
26 | 'unknown'
27 | ]
28 |
29 | const Properties = ({ properties, active = true, dispatch, suite, test = null }) => {
30 | return
31 |
39 | {active
40 | ?
41 |
42 |
43 |
44 | Property |
45 | Value |
46 |
47 |
48 |
49 | {Object.keys(properties)
50 | .filter(key => key !== '_active' && key !== '_visible')
51 | .map(key => {
52 | return
53 | {key} |
54 | {properties[key].join(', ')} |
55 |
56 | })}
57 |
58 |
59 |
60 | : null}
61 |
62 | }
63 |
64 | const RawContent = ({ messages }) =>
65 | {messages.map((message, index) =>
{message}
)}
66 |
67 |
68 | const PrettyContent = ({ messages }) =>
69 | {messages.map((message, index) =>
)}
70 |
71 |
72 | const PrettyIcon = () =>
73 |
74 |
75 |
76 | const CodeIcon = () =>
77 |
78 |
79 |
80 | const Test = ({
81 | printMode,
82 | id,
83 | messages,
84 | status,
85 | time,
86 | classname,
87 | name,
88 | properties = {},
89 | active = true,
90 | raw = true,
91 | dispatch,
92 | suite
93 | }) => {
94 | const hasProperties = properties._visible & Object.keys(properties).filter(key => key !== '_active' && key !== '_visible').length > 0
95 | const hasMessage = messages.length > 0
96 | const Wrapper = printMode ? RenderAlways : RenderIfVisible
97 |
98 | return (
99 |
100 |
101 |
118 |
119 | {active && (hasMessage || hasProperties)
120 | ?
121 | {hasProperties ?
: null}
122 | {
123 | hasMessage
124 | ? <>
125 |
}
129 | offIcon={
}
130 | offLabel='pretty'
131 | onChange={() => dispatch({ type: 'toggle-test-mode', payload: { suite, id, raw: !raw } })} />
132 | {
133 | raw
134 | ?
135 | :
136 | }
137 | >
138 | : null
139 | }
140 |
141 | : null}
142 |
143 |
144 |
145 | )
146 | }
147 |
148 | const Suite = ({
149 | visible,
150 | id,
151 | name,
152 | active = false,
153 | properties = {},
154 | time,
155 | tests = {},
156 | dispatch,
157 | systemOut = [],
158 | printMode = false
159 | }) => {
160 | let passed = 0
161 | let failure = 0
162 | let skipped = 0
163 | let error = 0
164 | let unknown = 0
165 | Object.keys(tests).forEach(key => {
166 | const status = tests[key].status
167 | if (status === 'passed') passed += 1
168 | else if (status === 'failure') failure += 1
169 | else if (status === 'skipped') skipped += 1
170 | else if (status === 'error') error += 1
171 | else unknown += 1
172 | })
173 |
174 | const Wrapper = printMode ? RenderAlways : RenderIfVisible
175 |
176 | const { query } = useVisibility()
177 |
178 | const hasTests = Object.keys(tests).length > 0 && Object.values(tests).some(test => query[test.status])
179 | const hasProperties = '_visible' in properties && properties._visible && Object.keys(properties).filter(key => key !== '_active' && key !== '_visible').length > 0
180 | const containsSomething = hasTests || hasProperties
181 | return (
182 |
183 |
184 |
207 | {active && containsSomething
208 | ?
209 |
210 | {systemOut.length > 0 ? systemOut.map((value, index) =>
{value}
) : null}
211 | {hasProperties ?
: null}
212 |
213 | {
214 | Object.entries(tests)
215 | .filter(([key, test]) => query[test.status])
216 | .sort((left, right) => {
217 | let leftStatus = statusRank.indexOf(left[1].status)
218 | let rightStatus = statusRank.indexOf(right[1].status)
219 |
220 | leftStatus = leftStatus === -1 ? statusRank.length : leftStatus
221 | rightStatus = rightStatus === -1 ? statusRank.length : rightStatus
222 |
223 | if (leftStatus < rightStatus) {
224 | return -2
225 | }
226 | if (leftStatus > rightStatus) return 2
227 |
228 | const leftName = left[1].name
229 | const rightName = right[1].name
230 |
231 | if (leftName < rightName) return -1
232 | if (leftName > rightName) return 1
233 |
234 | return 0
235 | })
236 | .map(([key, test]) => )
237 | }
238 |
239 |
240 |
241 | : null}
242 |
243 |
244 | )
245 | }
246 |
247 | export default Suite
248 |
--------------------------------------------------------------------------------
/src/app/test-options.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Toggle from './toggle.js'
3 | import useVisibility from './visible.js'
4 |
5 | const icons = {
6 | passed: 'check',
7 | failure: 'times',
8 | error: 'exclamation',
9 | skipped: 'ban',
10 | unknown: 'question'
11 | }
12 |
13 | const Search = ({ label, dispatch, suite, id }) =>
14 |
15 | {
17 | dispatch({
18 | type: 'search-tests',
19 | payload: {
20 | value: evt.target.value
21 | }
22 | })
23 | }}
24 | className='input'
25 | type='text'
26 | placeholder={label} />
27 |
28 |
29 |
30 | const Total = ({ count, total, icon }) =>
31 | {icon
32 | ?
33 |
34 |
35 | : null}
36 | {count}/{total}
37 |
38 |
39 | const fromTestCounts = (testCounts, status, value) => {
40 | const statusCounts = testCounts[status] || {}
41 | return statusCounts[value] || 0
42 | }
43 |
44 | const EyeIcon = () => <>
45 |
46 |
47 |
48 | >
49 |
50 | const EyeSlashIcon = () => <>
51 |
52 |
53 |
54 | >
55 |
56 | const PrettyIcon = () =>
57 |
58 |
59 |
60 | const CodeIcon = () =>
61 |
62 |
63 |
64 | const ChevronUpIcon = () =>
65 |
66 |
67 |
68 | const ChevronDownIcon = () =>
69 |
70 |
71 |
72 | const StatusTotal = ({ testCounts, status }) => {
73 | return fromTestCounts(testCounts, status, 'total') > 0 ? : null
74 | }
75 |
76 | const ToggleRow = ({ status, label, dispatch, visible = true, expanded = true, raw = true }) => {
77 | const { query, setSearchParams } = useVisibility()
78 | return
79 |
80 | {status !== 'all'
81 | ?
82 |
83 |
84 | :
85 |
86 | }
87 | {label}
88 |
89 |
{
91 | if (status !== 'all') {
92 | setSearchParams({
93 | ...query,
94 | [status]: !visible
95 | })
96 | } else {
97 | const anyFalse = Object.values(query).some(visible => visible === false)
98 | setSearchParams({
99 | passed: anyFalse,
100 | skipped: anyFalse,
101 | failure: anyFalse,
102 | error: anyFalse,
103 | unknown: anyFalse
104 | })
105 | }
106 | }}
107 | active={visible}
108 | onLabel='Visible'
109 | offLabel='Hidden'
110 | onIcon={}
111 | offIcon={} />
112 | {
114 | dispatch({
115 | type: 'toggle-test-expanded',
116 | payload: {
117 | status,
118 | active: !expanded
119 | }
120 | })
121 | }}
122 | active={expanded}
123 | onLabel='Expanded'
124 | offLabel='Contracted'
125 | onIcon={}
126 | offIcon={} />
127 | {
129 | dispatch({
130 | type: 'toggle-test-raw',
131 | payload: {
132 | status,
133 | active: !raw
134 | }
135 | })
136 | }}
137 | active={raw}
138 | onLabel='Raw'
139 | offLabel='Pretty'
140 | onIcon={}
141 | offIcon={} />
142 |
143 | }
144 |
145 | const Options = ({
146 | testCounts = {},
147 | testToggles = {},
148 | count = 0,
149 | total = 0,
150 | dispatch,
151 | active = false
152 | }) => {
153 | const { all, query: { passed, failure, error, skipped, unknown } } = useVisibility()
154 |
155 | return
156 |
174 |
175 | {active
176 | ? <>
177 |
178 |
179 |
180 |
181 |
182 |
183 | >
184 | : null}
185 |
186 |
187 |
188 | }
189 |
190 | export default Options
191 |
--------------------------------------------------------------------------------
/src/app/test-options.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import TestOptions from './test-options'
3 | import renderer from 'react-test-renderer'
4 |
5 | test('renders test options', () => {
6 | const tree = renderer.create().toJSON()
7 | expect(tree).toMatchSnapshot()
8 | })
9 |
--------------------------------------------------------------------------------
/src/app/toggle.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Toggle = ({
4 | active,
5 | onIcon,
6 | offIcon,
7 | onLabel,
8 | offLabel,
9 | disabled = false,
10 | onChange = () => {},
11 | className = ''
12 | }) => {
13 | return
25 | }
26 |
27 | export default Toggle
28 |
--------------------------------------------------------------------------------
/src/app/toggle.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Toggle from './toggle'
3 | import renderer from 'react-test-renderer'
4 |
5 | test('renders toggle', () => {
6 | const tree = renderer.create().toJSON()
7 | expect(tree).toMatchSnapshot()
8 | })
9 |
--------------------------------------------------------------------------------
/src/app/visible.js:
--------------------------------------------------------------------------------
1 | import queryString from 'query-string'
2 | import { useSearchParams } from 'react-router-dom'
3 |
4 | const useVisibility = () => {
5 | const [searchParams, setSearchParams] = useSearchParams()
6 | const query = queryString.parse(searchParams.toString(), { parseBooleans: true })
7 |
8 | const passed = ('passed' in query ? query.passed : true)
9 | const skipped = ('skipped' in query ? query.skipped : true)
10 | const error = 'error' in query ? query.error : true
11 | const failure = 'failure' in query ? query.failure : true
12 | const unknown = 'unknown' in query ? query.unknown : true
13 |
14 | let fastFilter = 3
15 | if (passed && skipped && error && failure && unknown) fastFilter = 2
16 | else if (passed && skipped && !error && !failure && !unknown) fastFilter = 1
17 | else if (!passed && !skipped && error && failure && unknown) fastFilter = 0
18 |
19 | return {
20 | query: {
21 | passed,
22 | skipped,
23 | error,
24 | failure,
25 | unknown
26 | },
27 | all: passed && skipped && error && failure && unknown,
28 | fastFilter,
29 | setSearchParams
30 | }
31 | }
32 |
33 | export default useVisibility
34 |
--------------------------------------------------------------------------------
/src/cli/args.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import yargs from 'yargs/yargs'
3 | import { hideBin } from 'yargs/helpers'
4 |
5 | const instance = yargs(hideBin(process.argv))
6 | .command('xunit-viewer', 'Renders Xunit style xml results')
7 | .example('xunit-viewer -r file.xml', 'a file')
8 | .example('xunit-viewer -r folder', 'a folder')
9 | .example('xunit-viewer -r folder -i *-broke.xml', 'ignore')
10 | .example('xunit-viewer -r folder -o my-tests.html', 'rename output')
11 | .example('xunit-viewer -r folder -t "My Tests"', 'change HTML title')
12 | .example('xunit-viewer -r folder -b https://image.png', 'change the image')
13 | .example('xunit-viewer -r folder -f https://image.favico', 'change the favicon')
14 | .example('xunit-viewer -r folder -c', 'render in console')
15 | .example('xunit-viewer -r folder -c -C', 'render in console, do not clear')
16 | .example('xunit-viewer -r folder -c -n', 'no color in console')
17 | .example('xunit-viewer -r folder -w', 'start watch')
18 | .example('xunit-viewer -r folder -w -p 5050', 'watch at 5050')
19 | // .example('xunit-viewer -r folder --s.s "value"', 'search suite with term "value"')
20 |
21 | .string('results')
22 | .coerce('results', (arg) => path.resolve(process.cwd(), arg))
23 | .alias('r', 'results')
24 | .describe('r', 'File/Folder of results')
25 | .demandOption(['results'])
26 |
27 | .array('ignore')
28 | .alias('i', 'ignore')
29 | .describe('i', 'Ignore patterns')
30 |
31 | .string('output')
32 | .default('output', 'index.html')
33 | .coerce('output', (arg) => {
34 | if (arg === 'false') return false
35 | return path.resolve(process.cwd(), arg.endsWith('.html') ? arg : `${arg}.html`)
36 | })
37 | .alias('o', 'output')
38 | .describe('o', 'Output filename')
39 |
40 | .string('title')
41 | .alias('t', 'title')
42 | .describe('t', 'HTML title e.g. "My Tests"')
43 |
44 | .string('brand')
45 | .alias('b', 'brand')
46 | .describe('b', 'Provide a URL with your own logo')
47 |
48 | .string('favicon')
49 | .alias('f', 'favicon')
50 | .describe('f', 'Provide a URL with your own favicon')
51 |
52 | .boolean('console')
53 | .alias('c', 'console')
54 | .describe('c', 'Render in console')
55 |
56 | .boolean('clear')
57 | .alias('C', 'clear')
58 | .default('C', true)
59 | .describe('C', 'Clears the console')
60 |
61 | .boolean('server')
62 | .default('s', false)
63 | .alias('s', 'server')
64 | .describe('s', 'Start a server and sockets for live updates')
65 |
66 | .boolean('no-color')
67 | .alias('n', 'no-color')
68 | .describe('n', 'No color in the console')
69 |
70 | .boolean('watch')
71 | .alias('w', 'watch')
72 | .describe('w', 'Re-run when a file changes')
73 |
74 | .number('port')
75 | .alias('p', 'port')
76 | .describe('p', 'Starts a server with sockets on that port, if no port is provided then it will run on port 3000 (or next available)')
77 |
78 | // .string('properties.search')
79 | // .alias('p.s', 'properties.search')
80 | // .describe('p.s', 'pre-filter option')
81 |
82 | // .boolean('properties.visible')
83 | // .alias('p.v', 'properties.visible')
84 | // .describe('p.v', 'pre-filter option')
85 |
86 | // .string('suites.search')
87 | // .alias('s.s', 'suites.search')
88 | // .describe('s.s', 'pre-filter option')
89 |
90 | // const types = ['tests']
91 | // const statuses = ['passed', 'failure', 'skipped', 'error', 'unknown']
92 | // const actions = ['visible']
93 |
94 | // types.forEach(type => {
95 | // const firstTypeChar = type[0]
96 | // const searchCommand = `${type}.search`
97 | // const searchAlias = `${firstTypeChar}.s`
98 |
99 | // instance.string(searchCommand)
100 | // .alias(searchAlias, searchCommand)
101 | // .describe(searchAlias, 'pre-filter option')
102 |
103 | // statuses.forEach(status => {
104 | // const firstStatusChar = status[0]
105 | // actions.forEach(action => {
106 | // const firstActionChar = action[0]
107 | // const toggleCommand = `${type}.${status}.${action}`
108 | // const toggleAlias = `${firstTypeChar}.${firstStatusChar}.${firstActionChar}`
109 |
110 | // instance.boolean(toggleCommand)
111 | // .alias(toggleAlias, toggleCommand)
112 | // .describe(toggleAlias, 'pre-filter option')
113 | // })
114 | // })
115 | // })
116 |
117 | instance.help()
118 |
119 | export const args = instance.argv
120 | export const showHelp = instance.showHelp
121 |
--------------------------------------------------------------------------------
/src/cli/get-description.js:
--------------------------------------------------------------------------------
1 | export default (suites) => {
2 | const testCounts = {}
3 | Object.values(suites.suites).forEach((suite) => {
4 | Object.values(suite.tests).forEach((test) => {
5 | const status = test.status || 'unknown'
6 | testCounts[status] = testCounts[status] || 0
7 | testCounts[status] += 1
8 | })
9 | })
10 | return Object.entries(testCounts)
11 | .map(([status, count]) => {
12 | return `${count} ${status}`
13 | })
14 | .join(', ')
15 | }
16 |
--------------------------------------------------------------------------------
/src/cli/get-description.test.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import getDescription from './get-description.js'
4 | import './parse'
5 |
6 | const parse = window.parse
7 |
8 | const dataDir = path.resolve(__dirname, '../../data')
9 |
10 | test('get description', async () => {
11 | const data = path.join(dataDir, '/test.xml')
12 | const parsed = await parse(fs.readFileSync(data).toString())
13 | const result = getDescription(parsed)
14 | expect(result).toBe('13 passed, 2 failure, 1 error, 1 unknown, 1 skipped')
15 | })
16 |
--------------------------------------------------------------------------------
/src/cli/get-files.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import languageEncoding from 'detect-file-encoding-and-language'
4 |
5 | const getFiles = (logger, ignore, folder, files = []) => {
6 | if (fs.lstatSync(folder).isDirectory()) {
7 | fs.readdirSync(folder)
8 | .map(name => path.join(folder, name))
9 | .forEach(file => {
10 | if (fs.lstatSync(file).isDirectory()) files = files.concat(getFiles(logger, ignore, file, files))
11 | else if (file.endsWith('.xml') && !ignore.some(pattern => file.includes(pattern) || new RegExp(pattern).test(file))) files.push(file)
12 | else console.log(logger.warning('IGNORING:'), logger.file(file))
13 | })
14 | } else {
15 | return Array.from(new Set([folder]))
16 | }
17 | return Array.from(new Set(files))
18 | }
19 |
20 | export default async (logger, { results, ignore = [] }) => {
21 | const files = getFiles(logger, ignore, results)
22 | const readFiles = []
23 | for (const file of files) {
24 | const { encoding } = await languageEncoding(file)
25 | readFiles.push({
26 | file,
27 | contents: fs.readFileSync(file).toString((encoding || 'utf8').toLowerCase().replace(/-/g, ''))
28 | })
29 | }
30 |
31 | return readFiles
32 | }
33 |
--------------------------------------------------------------------------------
/src/cli/get-files.test.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import getFiles from './get-files'
3 |
4 | const expectedFiles = [
5 | path.resolve(__dirname, '../../data/class_not_classname.xml'),
6 | path.resolve(__dirname, '../../data/complete_no_suite.xml'),
7 | path.resolve(__dirname, '../../data/complete_no_suite_multi_cases.xml'),
8 | path.resolve(__dirname, '../../data/complete_no_suite_single_suite.xml'),
9 | path.resolve(__dirname, '../../data/complete_single_case_only.xml'),
10 | path.resolve(__dirname, '../../data/complete_single_suite.xml'),
11 | path.resolve(__dirname, '../../data/defect_suite.xml'),
12 | path.resolve(__dirname, '../../data/duplicate_name_unique_classanme.xml'),
13 | path.resolve(__dirname, '../../data/embedded_html_sysout.xml'),
14 | path.resolve(__dirname, '../../data/error_suite.xml'),
15 | path.resolve(__dirname, '../../data/failing_suite.xml'),
16 | path.resolve(__dirname, '../../data/invalid.xml'),
17 | path.resolve(__dirname, '../../data/issue_2.xml'),
18 | path.resolve(__dirname, '../../data/issue_3.xml'),
19 | path.resolve(__dirname, '../../data/lots-of-results.xml'),
20 | path.resolve(__dirname, '../../data/malformed.xml'),
21 | path.resolve(__dirname, '../../data/most_complex.xml'),
22 | path.resolve(__dirname, '../../data/multi-name-unique-classname.xml'),
23 | path.resolve(__dirname, '../../data/multi_cases.xml'),
24 | path.resolve(__dirname, '../../data/multi_error_test_with_system_out.xml'),
25 | path.resolve(__dirname, '../../data/multi_suite.xml'),
26 | path.resolve(__dirname, '../../data/name.has.dots.xml'),
27 | path.resolve(__dirname, '../../data/nested-nested.xml'),
28 | path.resolve(__dirname, '../../data/no_class_name.xml'),
29 | path.resolve(__dirname, '../../data/passing_suite.xml'),
30 | path.resolve(__dirname, '../../data/properties_in_test_meta.xml'),
31 | path.resolve(__dirname, '../../data/pytest_testcase_properties.xml'),
32 | path.resolve(__dirname, '../../data/russian-unicode.xml'),
33 | path.resolve(__dirname, '../../data/semi-colon.xml'),
34 | path.resolve(__dirname, '../../data/skipped_suite.xml'),
35 | path.resolve(__dirname, '../../data/special_chars_suite.xml'),
36 | path.resolve(__dirname, '../../data/suite-system-out.xml'),
37 | path.resolve(__dirname, '../../data/test-system-out.xml'),
38 | path.resolve(__dirname, '../../data/test.xml'),
39 | path.resolve(__dirname, '../../data/utf-16.xml'),
40 | path.resolve(__dirname, '../../data/with_html.xml'),
41 | path.resolve(__dirname, '../../data/xunit-2-2.xml'),
42 | path.resolve(__dirname, '../../data/xunit-2.xml')
43 | ]
44 |
45 | test('get files', async () => {
46 | const files = await getFiles({
47 | warning: (input) => input,
48 | file: (input) => input
49 | }, { results: path.resolve(__dirname, '../../data'), ignore: ['_thingy'] })
50 | expect(files.map(({ file }) => file)).toEqual(expectedFiles)
51 | expect(files.map(({ file }) => file)).toEqual(expect.not.arrayContaining([path.resolve(__dirname, '../../data/subfolder/_thingy.xml')]))
52 | expect(files.filter(({ contents }) => contents === '').length).toBe(0)
53 | })
54 |
--------------------------------------------------------------------------------
/src/cli/get-suites.js:
--------------------------------------------------------------------------------
1 | import merge from 'merge'
2 | import parse from './parse.js'
3 |
4 | let parseXml = parse
5 | if (typeof window !== 'undefined') parseXml = window.parse
6 |
7 | export default async (logger, files) => {
8 | let suites = {}
9 | for (const { file, contents } of files) {
10 | try {
11 | const res = await parseXml(contents)
12 | suites = merge.recursive(true, suites, res)
13 | } catch (err) {
14 | console.log(logger.error('Failed to parse'), logger.file(file), '\n', logger.error(err.message), '\n')
15 | }
16 | }
17 | return suites
18 | }
19 |
--------------------------------------------------------------------------------
/src/cli/get-suites.test.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import getFiles from './get-files'
3 | import getSuites from './get-suites'
4 | import expected from './get-suites-expected.json'
5 |
6 | const logger = {
7 | warning: input => input,
8 | file: input => input,
9 | error: input => input
10 | }
11 |
12 | test('get suites', async () => {
13 | const files = await getFiles(logger, { results: path.resolve(__dirname, '../../data') })
14 | const suites = await getSuites(logger, files)
15 | expect(suites).toEqual(expected)
16 | })
17 |
--------------------------------------------------------------------------------
/src/cli/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{title}}
7 |
8 |
9 |
10 |
11 |
12 |
15 |
16 |
17 |
18 |
19 |
95 | {{#if useSockets}}
96 |
97 | {{/if}}
98 |
103 |
106 |
107 |
108 |
--------------------------------------------------------------------------------
/src/cli/logger.js:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk'
2 |
3 | export default (noColor) => ({
4 | server: (message, address) => {
5 | if (noColor) return `${message} ${address}`
6 | return `${chalk.bold(message)} ${chalk.underline.blueBright(address)}`
7 | },
8 | property: (key, values) => {
9 | if (noColor) return `${key}=${values.join(', ')}`
10 | return `${chalk.bold.cyan(key)}=${chalk.cyan(values.join(', '))}`
11 | },
12 | test: (status, name) => {
13 | let icon = '?'
14 | if (status === 'failure') icon = '✗'
15 | if (status === 'passed') icon = '✓'
16 | if (status === 'error') icon = '!'
17 | if (status === 'skipped') icon = '⊘'
18 |
19 | if (noColor) return `${icon} ${name}`
20 |
21 | if (status === 'failure') return chalk.red(`${icon} ${name}`)
22 | if (status === 'passed') return chalk.green(`${icon} ${name}`)
23 | if (status === 'error') return chalk.yellow(`${icon} ${name}`)
24 | if (status === 'skipped') return chalk.gray(`${icon} ${name}`)
25 | return chalk.blueBright(`${icon} ${name}`)
26 | },
27 | time: (time) => {
28 | if (noColor) return `time=${time}`
29 | return `${chalk.bold.cyan('time')}=${chalk.cyan(time)}`
30 | },
31 | suite: (message) => {
32 | if (noColor) return message
33 | return chalk.yellow(message)
34 | },
35 | error: (message) => {
36 | if (noColor) return message
37 | return chalk.red(message)
38 | },
39 | warning: (message) => {
40 | if (noColor) return message
41 | return chalk.yellow(message)
42 | },
43 | file: (message) => {
44 | if (noColor) return message
45 | return chalk.underline.blueBright(message)
46 | }
47 | })
48 |
--------------------------------------------------------------------------------
/src/cli/parse-expected.json:
--------------------------------------------------------------------------------
1 | {
2 | "suites": {
3 | "390294658": {
4 | "tests": {
5 | "-1319400288": {
6 | "id": -1319400288,
7 | "name": "testsuite in a testcase testcase",
8 | "messages": [],
9 | "visible": true,
10 | "time": 0,
11 | "classname": "",
12 | "status": "passed"
13 | }
14 | },
15 | "systemOut": [],
16 | "properties": {
17 | "_visible": true
18 | },
19 | "id": 390294658,
20 | "name": "testsuite in a testcase",
21 | "time": 0,
22 | "_visible": true
23 | },
24 | "1201687819": {
25 | "tests": {
26 | "-2126675373": {
27 | "id": -2126675373,
28 | "name": "testcase 1",
29 | "messages": [],
30 | "visible": true,
31 | "time": 0,
32 | "classname": "",
33 | "status": "passed"
34 | },
35 | "-2126675372": {
36 | "id": -2126675372,
37 | "name": "testcase 2",
38 | "messages": [],
39 | "visible": true,
40 | "time": 0,
41 | "classname": "",
42 | "status": "passed"
43 | }
44 | },
45 | "systemOut": [],
46 | "properties": {
47 | "_visible": true
48 | },
49 | "id": 1201687819,
50 | "name": "duplicate",
51 | "time": 0,
52 | "_visible": true
53 | },
54 | "1870281583": {
55 | "tests": {
56 | "31726797": {
57 | "id": 31726797,
58 | "name": "with properties",
59 | "messages": [
60 | "message 1"
61 | ],
62 | "visible": true,
63 | "time": 0,
64 | "classname": "",
65 | "properties": {
66 | "_visible": true,
67 | "name only": [],
68 | "prop 1": [
69 | "value 1",
70 | "value 2"
71 | ],
72 | "prop 2": [
73 | "value"
74 | ],
75 | "No Name": [
76 | "value with no name",
77 | "value only"
78 | ],
79 | "seperate props": [
80 | "value"
81 | ]
82 | },
83 | "status": "passed"
84 | }
85 | },
86 | "systemOut": [],
87 | "properties": {
88 | "_visible": true
89 | },
90 | "id": 1870281583,
91 | "name": "testcase with properties",
92 | "time": 0,
93 | "_visible": true
94 | },
95 | "-1861124855": {
96 | "tests": {
97 | "152530127": {
98 | "id": 152530127,
99 | "name": "simple passing test",
100 | "messages": [],
101 | "visible": true,
102 | "time": 0,
103 | "classname": "",
104 | "status": "passed"
105 | },
106 | "448559065": {
107 | "id": 448559065,
108 | "name": "test with time",
109 | "messages": [],
110 | "visible": true,
111 | "time": "time",
112 | "classname": "",
113 | "status": "passed"
114 | },
115 | "1350225396": {
116 | "id": 1350225396,
117 | "name": "failing test with messages",
118 | "messages": [
119 | "inner massage 1",
120 | "message prop 1",
121 | "inner massage 2",
122 | "message prop 2"
123 | ],
124 | "visible": true,
125 | "time": 0,
126 | "classname": "",
127 | "status": "failure"
128 | },
129 | "1443479640": {
130 | "id": 1443479640,
131 | "name": "test with messages",
132 | "messages": [
133 | "inner message",
134 | "message prop"
135 | ],
136 | "visible": true,
137 | "time": 0,
138 | "classname": "",
139 | "status": "passed"
140 | },
141 | "1669557569": {
142 | "id": 1669557569,
143 | "name": "the sub test",
144 | "messages": [],
145 | "visible": true,
146 | "time": 0,
147 | "classname": "",
148 | "status": "passed"
149 | },
150 | "1879989598": {
151 | "id": 1879989598,
152 | "name": "erroring test with messages",
153 | "messages": [
154 | "inner massage 1",
155 | "message prop 1",
156 | "inner massage 2",
157 | "message prop 2"
158 | ],
159 | "visible": true,
160 | "time": 0,
161 | "classname": "",
162 | "status": "error"
163 | },
164 | "2045602436": {
165 | "id": 2045602436,
166 | "name": "test passed with messages",
167 | "messages": [
168 | "inner massage 1",
169 | "message prop 1",
170 | "inner massage 2",
171 | "message prop 2"
172 | ],
173 | "visible": true,
174 | "time": 0,
175 | "classname": "",
176 | "status": "passed"
177 | },
178 | "-579348086": {
179 | "id": -579348086,
180 | "name": "No Name",
181 | "messages": [
182 | "only a message"
183 | ],
184 | "time": 0,
185 | "classname": ""
186 | },
187 | "-232186498": {
188 | "id": -232186498,
189 | "name": "test with sub test",
190 | "messages": [],
191 | "visible": true,
192 | "time": 0,
193 | "classname": "",
194 | "status": "passed"
195 | },
196 | "-986143044": {
197 | "id": -986143044,
198 | "name": "test passed with messages and free text",
199 | "messages": [
200 | "HERE IS SOME TEXT",
201 | "inner massage 1",
202 | "message prop 1"
203 | ],
204 | "visible": true,
205 | "time": 0,
206 | "classname": "",
207 | "status": "passed"
208 | },
209 | "-1154650731": {
210 | "id": -1154650731,
211 | "name": "skiping test with messages",
212 | "messages": [
213 | "inner massage 1",
214 | "message prop 1",
215 | "inner massage 2",
216 | "message prop 2"
217 | ],
218 | "visible": true,
219 | "time": 0,
220 | "classname": "",
221 | "status": "skipped"
222 | },
223 | "-198845521": {
224 | "id": -198845521,
225 | "name": "error and failure test with messages",
226 | "messages": [
227 | "failure inner massage 2",
228 | "failure message prop 2",
229 | "error inner massage 1",
230 | "error message prop 1"
231 | ],
232 | "visible": true,
233 | "time": 0,
234 | "classname": "",
235 | "status": "failure"
236 | }
237 | },
238 | "systemOut": [],
239 | "properties": {
240 | "_visible": true,
241 | "name only": [],
242 | "prop 1": [
243 | "value 1",
244 | "value 2"
245 | ],
246 | "prop 2": [
247 | "value"
248 | ],
249 | "No Name": [
250 | "value with no name",
251 | "value only"
252 | ],
253 | "seperate props": [
254 | "value"
255 | ]
256 | },
257 | "id": -1861124855,
258 | "name": "suite 1",
259 | "time": "time",
260 | "_visible": true
261 | },
262 | "-1861124854": {
263 | "tests": {
264 | "-1146345790": {
265 | "id": -1146345790,
266 | "name": "testcase",
267 | "messages": [],
268 | "visible": true,
269 | "time": 0,
270 | "classname": "",
271 | "status": "passed"
272 | }
273 | },
274 | "systemOut": [],
275 | "properties": {
276 | "_visible": true
277 | },
278 | "id": -1861124854,
279 | "name": "suite 2",
280 | "time": 0,
281 | "_visible": true
282 | },
283 | "-1028948070": {
284 | "tests": {
285 | "-1387098835": {
286 | "id": -1387098835,
287 | "name": "testcase duplicate",
288 | "messages": [
289 | "message 1",
290 | "message 2"
291 | ],
292 | "visible": true,
293 | "time": 0,
294 | "classname": "",
295 | "status": "passed"
296 | }
297 | },
298 | "systemOut": [],
299 | "properties": {
300 | "_visible": true
301 | },
302 | "id": -1028948070,
303 | "name": "suite with duplicate tests",
304 | "time": 0,
305 | "_visible": true
306 | }
307 | }
308 | }
--------------------------------------------------------------------------------
/src/cli/parse.js:
--------------------------------------------------------------------------------
1 | import xml2js from 'xml2js'
2 |
3 | const statusRank = [
4 | 'failure',
5 | 'error',
6 | 'passed',
7 | 'skipped',
8 | 'unknown'
9 | ]
10 |
11 | const parseString = (xml) => new Promise((resolve, reject) => {
12 | xml2js.parseString(xml, (err, result) => {
13 | if (err) reject(new Error(err))
14 | else resolve(result)
15 | })
16 | })
17 |
18 | const hashCode = (str) => {
19 | let hash = 0
20 | if (str.length === 0) return hash
21 | for (let i = 0; i < str.length; i++) {
22 | const char = str.charCodeAt(i)
23 | hash = ((hash << 5) - hash) + char
24 | hash = hash & hash
25 | }
26 | return hash
27 | }
28 |
29 | const extarctSuiteMeta = (output, testsuite) => {
30 | const meta = testsuite.$ || {}
31 | const name = meta.name || 'No Name'
32 | const id = hashCode(name)
33 | const suite = output.suites[id] || {}
34 | suite.tests = suite.tests || {}
35 | suite.systemOut = suite.systemOut || []
36 | suite.properties = suite.properties || {
37 | _visible: true
38 | }
39 |
40 | Object.entries(meta).forEach(([key, value]) => {
41 | if (!['errors', 'failures', 'name', 'skipped', 'tests', 'time'].includes(key)) {
42 | suite.properties[key] = suite.properties[key] || []
43 | suite.properties[key].push(value)
44 | }
45 | })
46 |
47 | suite.id = id
48 | suite.name = name
49 | suite.time = meta.time || 0
50 | return suite
51 | }
52 |
53 | const extractProperties = (suite, properties) => {
54 | suite.properties = suite.properties || {}
55 | suite.properties._visible = true
56 | properties.forEach(property => {
57 | if (typeof property === 'string') {
58 | property = property.trim()
59 | if (property !== '') {
60 | suite.properties['No Name'] = suite.properties['No Name'] || []
61 | suite.properties['No Name'].push(property)
62 | }
63 | } else {
64 | property.property.forEach(property => {
65 | const meta = property.$ || {}
66 | const name = meta.name || 'No Name'
67 | let value = meta.value || property._
68 | if (typeof property === 'string') value = property
69 | value = value || ''
70 | value = value.trim()
71 | suite.properties[name] = suite.properties[name] || []
72 | if (value) {
73 | suite.properties[name].push(value)
74 | }
75 | })
76 | }
77 | })
78 | }
79 |
80 | const extractTestMessages = (test, messages) => {
81 | messages.forEach(body => {
82 | const is_ = typeof body._ === 'string'
83 | const is$Message = typeof body.$ !== 'undefined' && ('message' in body.$)
84 | const is$Type = typeof body.$ !== 'undefined' && ('type' in body.$)
85 | const isString = typeof body === 'string'
86 |
87 | if (is_) test.messages.push(body._.trim())
88 | if (is$Message) test.messages.push(body.$.message.trim())
89 | if (is$Type) test.messages.push(body.$.type.trim())
90 | if (isString) test.messages.push(body.trim())
91 | })
92 | }
93 |
94 | const extractTests = (output, suite, testcases, defaultVisibility) => {
95 | suite.tests = suite.tests || {}
96 | testcases.forEach(testcase => {
97 | const meta = testcase.$ || {}
98 | const name = meta.name || 'No Name'
99 | const classname = meta.classname || meta.class || ''
100 | const time = meta.time || 0
101 | const id = hashCode(name + classname)
102 |
103 | const test = suite.tests[id] || { id, name, messages: [], visible: true }
104 | test.time = time
105 | test.classname = classname
106 | if (typeof testcase === 'string') test.messages.push(testcase.trim())
107 | if (testcase._) test.messages.push(testcase._.trim())
108 | if (meta.message) test.messages.push(testcase.$.message.trim())
109 | if (typeof testcase.properties !== 'undefined') {
110 | extractProperties(test, testcase.properties)
111 | delete testcase.properties
112 | }
113 | const clonedMeta = Object.assign({}, meta)
114 | delete clonedMeta.time
115 | delete clonedMeta.name
116 | delete clonedMeta.classname
117 | delete clonedMeta.class
118 | delete clonedMeta.message
119 | if (Object.keys(clonedMeta).length > 0) {
120 | const property = []
121 | for (const [name, value] of Object.entries(clonedMeta)) {
122 | property.push({
123 | $: {
124 | name, value
125 | }
126 | })
127 | }
128 | extractProperties(test, [{ property }])
129 | }
130 |
131 | if (typeof testcase !== 'string') {
132 | const keys = Object.keys(testcase).filter(key => key !== '$' && key !== '_' && key !== 'testcase')
133 | .sort((left, right) => {
134 | let leftStatus = statusRank.indexOf(left)
135 | let rightStatus = statusRank.indexOf(right)
136 | leftStatus = leftStatus === -1 ? statusRank.length : leftStatus
137 | rightStatus = rightStatus === -1 ? statusRank.length : rightStatus
138 |
139 | if (leftStatus < rightStatus) return -1
140 | if (leftStatus > rightStatus) return 1
141 | return 0
142 | })
143 | let status = keys[0]
144 | keys.forEach((key) => {
145 | if (key) extractTestMessages(test, testcase[key])
146 | })
147 | if (status === 'system-out') status = 'passed'
148 | test.status = status || 'passed'
149 | }
150 |
151 | test.messages = test.messages.filter(message => message !== '')
152 |
153 | test.visible = defaultVisibility[test.status]
154 | suite.tests[id] = test
155 | if (typeof testcase.testcase !== 'undefined') extractTests(output, suite, testcase.testcase, defaultVisibility)
156 | if (typeof testcase.testsuite !== 'undefined') extractSuite(output, testcase.testsuite, defaultVisibility)
157 | })
158 | }
159 |
160 | const extractSystemOut = (suite, testsuite) => {
161 | suite.systemOut = suite.systemOut || []
162 | let systemOut = testsuite['system-out']
163 | if (!Array.isArray(systemOut)) systemOut = [systemOut]
164 | suite.systemOut = suite.systemOut.concat(systemOut)
165 | }
166 |
167 | const extractSuite = (output, testsuites, defaultVisibility) => {
168 | if (!Array.isArray(testsuites)) testsuites = [testsuites]
169 | testsuites.forEach(testsuite => {
170 | const suite = extarctSuiteMeta(output, testsuite)
171 | if (typeof testsuite.properties !== 'undefined') extractProperties(suite, testsuite.properties)
172 | if (typeof testsuite.testcase !== 'undefined') extractTests(output, suite, testsuite.testcase, defaultVisibility)
173 | if (typeof testsuite['system-out'] !== 'undefined') extractSystemOut(suite, testsuite)
174 | output.suites[suite.id] = suite
175 | })
176 | }
177 |
178 | const extract = (output, testsuites, defaultVisibility) => {
179 | if (!Array.isArray(testsuites)) testsuites = [testsuites]
180 | testsuites.forEach(testsuite => {
181 | extractSuite(output, testsuite, defaultVisibility)
182 | if (typeof testsuite.testsuite !== 'undefined') extract(output, testsuite.testsuite, defaultVisibility)
183 | })
184 | }
185 |
186 | const parse = async (xml, defaultVisibility = { passed: true, failure: true, skipped: true, unknown: true, error: true }) => {
187 | const output = {
188 | suites: {}
189 | }
190 | const result = await parseString(xml)
191 | if (result.testsuites) {
192 | const testsuites = result.testsuites.testsuite
193 | extract(output, testsuites, defaultVisibility)
194 | } else if (result.testsuite) {
195 | extract(output, result.testsuite, defaultVisibility)
196 | }
197 |
198 | for (const value of Object.values(output.suites)) {
199 | value._visible = Object.keys(value.tests).length > 0 || Object.keys(value.properties).filter(prop => prop !== '_visible').length > 0
200 | value.systemOut = value.systemOut.map(value => value.trim())
201 | }
202 | return output
203 | }
204 |
205 | if (typeof window !== 'undefined') window.parse = parse
206 |
207 | export default parse
208 |
--------------------------------------------------------------------------------
/src/cli/parse.test.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import './parse'
4 | import expected from './parse-expected.json'
5 |
6 | const parse = window.parse
7 | const dataDir = path.resolve(__dirname, '../../data')
8 |
9 | test('complete multi suites', async () => {
10 | const data = path.join(dataDir, '/test.xml')
11 | const result = await parse(fs.readFileSync(data).toString())
12 | expect(result).toEqual(expected)
13 | })
14 |
--------------------------------------------------------------------------------
/src/cli/render.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import Handlebars from 'handlebars'
4 | import LZUTF8 from 'lzutf8'
5 |
6 | import { fileURLToPath } from 'url'
7 | const __filename = fileURLToPath(import.meta.url)
8 | const __dirname = path.dirname(__filename)
9 |
10 | const staticDir = path.resolve(__dirname, './static')
11 |
12 | const getHTML = (type) => {
13 | const dir = path.join(staticDir, type)
14 | return fs.readdirSync(dir)
15 | .filter(file => file.endsWith(`.${type}`) && !file.includes('runtime'))
16 | .map(file => fs.readFileSync(path.join(dir, file)).toString())
17 | .join('\n')
18 | }
19 |
20 | export default (logger, files, description, { title = 'Xunit Viewer', brand, favicon }, useSockets = false) => {
21 | const scripts = getHTML('js')
22 | const styles = getHTML('css')
23 |
24 | const template = Handlebars.compile(fs.readFileSync(path.resolve(__dirname, 'index.html')).toString())
25 |
26 | files = files.map(({ file, contents }) => ({ file, contents: LZUTF8.compress(contents, { outputEncoding: 'Base64' }) }))
27 |
28 | return template({
29 | files: JSON.stringify(files),
30 | scripts,
31 | styles,
32 | title,
33 | icon: brand || 'https://lukejpreston.github.io/xunit-viewer/icon.png',
34 | favicon: favicon || 'https://lukejpreston.github.io/xunit-viewer/favicon.ico',
35 | brand,
36 | description,
37 | useSockets
38 | })
39 | }
40 |
--------------------------------------------------------------------------------
/src/cli/server.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import getPort from 'get-port'
3 | import HTTP from 'http'
4 | import ip from 'ip'
5 | import { Server } from 'socket.io'
6 | import getDescription from './get-description.js'
7 | import getFiles from './get-files.js'
8 | import getSuites from './get-suites.js'
9 | import render from './render.js'
10 | import watch from './watch.js'
11 |
12 | export default async (logger, args) => {
13 | const app = express()
14 | const http = HTTP.createServer(app)
15 | const io = new Server(http)
16 |
17 | app.get('/', async (req, res) => {
18 | const files = await getFiles(logger, args)
19 | const suites = await getSuites(logger, files)
20 | const description = getDescription(suites)
21 |
22 | res.send(render(logger, files, description, args, true))
23 | })
24 |
25 | io.on('connection', function (socket) {
26 | watch(args, () => {
27 | socket.emit('update', { files: getFiles(logger, args) })
28 | })
29 | })
30 |
31 | const port = await getPort({ port: args.port || 3000 })
32 | http.listen(port, function () {
33 | console.log(logger.server('Listening at', `http://${ip.address()}:${port}`))
34 | })
35 | }
36 |
--------------------------------------------------------------------------------
/src/cli/static/js/main.4e6e0818.js.LICENSE.txt:
--------------------------------------------------------------------------------
1 | /*!
2 | * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com
3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
4 | * Copyright 2023 Fonticons, Inc.
5 | */
6 |
7 | /*!
8 | * The buffer module from node.js, for the browser.
9 | *
10 | * @author Feross Aboukhadijeh
11 | * @license MIT
12 | */
13 |
14 | /*!
15 | LZ-UTF8 v0.6.3
16 |
17 | Copyright (c) 2021, Rotem Dan
18 | Released under the MIT license.
19 |
20 | Build date: 2022-07-06
21 |
22 | Please report any issue at https://github.com/rotemdan/lzutf8.js/issues
23 | */
24 |
25 | /*! http://mths.be/fromcodepoint v0.1.0 by @mathias */
26 |
27 | /*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh */
28 |
29 | /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
30 |
31 | /**
32 | * @license React
33 | * react-dom.production.min.js
34 | *
35 | * Copyright (c) Facebook, Inc. and its affiliates.
36 | *
37 | * This source code is licensed under the MIT license found in the
38 | * LICENSE file in the root directory of this source tree.
39 | */
40 |
41 | /**
42 | * @license React
43 | * react-jsx-runtime.production.min.js
44 | *
45 | * Copyright (c) Facebook, Inc. and its affiliates.
46 | *
47 | * This source code is licensed under the MIT license found in the
48 | * LICENSE file in the root directory of this source tree.
49 | */
50 |
51 | /**
52 | * @license React
53 | * react.production.min.js
54 | *
55 | * Copyright (c) Facebook, Inc. and its affiliates.
56 | *
57 | * This source code is licensed under the MIT license found in the
58 | * LICENSE file in the root directory of this source tree.
59 | */
60 |
61 | /**
62 | * @license React
63 | * scheduler.production.min.js
64 | *
65 | * Copyright (c) Facebook, Inc. and its affiliates.
66 | *
67 | * This source code is licensed under the MIT license found in the
68 | * LICENSE file in the root directory of this source tree.
69 | */
70 |
71 | /**
72 | * @remix-run/router v1.6.3
73 | *
74 | * Copyright (c) Remix Software Inc.
75 | *
76 | * This source code is licensed under the MIT license found in the
77 | * LICENSE.md file in the root directory of this source tree.
78 | *
79 | * @license MIT
80 | */
81 |
82 | /**
83 | * React Router DOM v6.13.0
84 | *
85 | * Copyright (c) Remix Software Inc.
86 | *
87 | * This source code is licensed under the MIT license found in the
88 | * LICENSE.md file in the root directory of this source tree.
89 | *
90 | * @license MIT
91 | */
92 |
93 | /**
94 | * React Router v6.13.0
95 | *
96 | * Copyright (c) Remix Software Inc.
97 | *
98 | * This source code is licensed under the MIT license found in the
99 | * LICENSE.md file in the root directory of this source tree.
100 | *
101 | * @license MIT
102 | */
103 |
--------------------------------------------------------------------------------
/src/cli/terminal.js:
--------------------------------------------------------------------------------
1 | import clear from 'console-clear'
2 |
3 | const statusRank = [
4 | 'failure',
5 | 'error',
6 | 'passed',
7 | 'skipped',
8 | 'unknown'
9 | ]
10 |
11 | export default (output, logger, description, args) => {
12 | const { suites } = output
13 | if (args.clear) clear()
14 | Object.values(suites)
15 | .sort((left, right) => {
16 | if (left.name < right.name) return -1
17 | if (left.name > right.name) return 1
18 | return 0
19 | })
20 | .forEach(suite => {
21 | console.log('\n', logger.suite(suite.name))
22 |
23 | const hasProperties = Object.entries(suite.properties)
24 | .filter(([key]) => key !== '_visible').length > 0
25 |
26 | if (hasProperties) {
27 | Object.entries(suite.properties)
28 | .filter(([key]) => key !== '_visible')
29 | .forEach(([key, values]) => {
30 | if (!Array.isArray) values = [values]
31 | console.log(' ', logger.property(key, values))
32 | })
33 | console.log()
34 | }
35 | Object.values(suite.tests)
36 | .sort((left, right) => {
37 | let leftStatus = statusRank.indexOf(left.status)
38 | let rightStatus = statusRank.indexOf(right.status)
39 |
40 | leftStatus = leftStatus === -1 ? statusRank.length : leftStatus
41 | rightStatus = rightStatus === -1 ? statusRank.length : rightStatus
42 |
43 | if (leftStatus < rightStatus) {
44 | return -2
45 | }
46 | if (leftStatus > rightStatus) return 2
47 |
48 | const leftName = left.name
49 | const rightName = right.name
50 |
51 | if (leftName < rightName) return -1
52 | if (leftName > rightName) return 1
53 |
54 | return 0
55 | })
56 | .forEach((test) => {
57 | logger.time(test.time)
58 | console.log(' ', logger.test(test.status || 'unknown', test.name), test.time ? logger.time(test.time) : '')
59 | if (test.messages.length > 0) {
60 | console.log(' -', test.messages.join('\n - '))
61 | }
62 | })
63 | })
64 | console.log()
65 | if (args.title) console.log(args.title)
66 | console.log(description)
67 | }
68 |
--------------------------------------------------------------------------------
/src/cli/update-expected.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import getFiles from './get-files'
4 | import getSuites from './get-suites'
5 | import parse from './parse'
6 | // const parse = window.parse
7 |
8 | import { fileURLToPath } from 'url'
9 | const __filename = fileURLToPath(import.meta.url)
10 | const __dirname = path.dirname(__filename)
11 |
12 | const logger = {
13 | warning: input => input,
14 | file: input => input,
15 | error: input => input
16 | }
17 |
18 | const main = async () => {
19 | const files = getFiles(logger, { results: path.resolve(__dirname, '../../data') })
20 | const suites = await getSuites(logger, files)
21 | fs.writeFileSync(path.resolve(__dirname, 'get-suites-expected.json'), JSON.stringify(suites, null, 2))
22 |
23 | const dataDir = path.resolve(__dirname, '../../data')
24 | const data = path.join(dataDir, '/test.xml')
25 | const result = await parse(fs.readFileSync(data).toString())
26 | fs.writeFileSync(path.resolve(__dirname, 'parse-expected.json'), JSON.stringify(result, null, 2))
27 | }
28 | main()
29 |
--------------------------------------------------------------------------------
/src/cli/watch.js:
--------------------------------------------------------------------------------
1 | import chokidar from 'chokidar'
2 | import debounce from 'debounce'
3 |
4 | export default ({ results }, cb) => {
5 | cb = debounce(cb)
6 | chokidar.watch(results)
7 | .on('all', (event, path) => {
8 | cb()
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import '@fortawesome/fontawesome-free/js/all.js'
2 | import 'bulma/css/bulma.css'
3 | import React from 'react'
4 | import ReactDOM from 'react-dom/client'
5 | import {
6 | createBrowserRouter,
7 | RouterProvider
8 | } from 'react-router-dom'
9 |
10 | import App from './app/app.js'
11 | import './app/index.css'
12 |
13 | import LZUTF8 from 'lzutf8'
14 |
15 | let files = window.files || []
16 | const title = window.title || 'Xunit Viewer'
17 | const brand = window.brand || null
18 |
19 | if (process.env.NODE_ENV === 'development') {
20 | files = [{
21 | file: '/path/to/file/complete.xml',
22 | contents: LZUTF8.compress(`
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | java.lang.RuntimeException: There was an error
40 |
41 |
42 | <i>WITH</i><b>HTML</b>
43 |
44 |
45 | For some reason a passing message
46 |
47 |
48 | This message has a link github.com/lukejpreston/xunit-viewer and an email example@gmail.com
49 |
50 |
51 | java.lang.RuntimeException: There was an error 1
52 | java.lang.RuntimeException: There was an error 2
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | `, { outputEncoding: 'Base64' })
84 | }]
85 | }
86 |
87 | files = files.map(({ file, contents }) => ({
88 | file,
89 | contents: LZUTF8.decompress(contents, { inputEncoding: 'Base64' })
90 | }))
91 |
92 | const router = createBrowserRouter([
93 | {
94 | path: '/',
95 | element: ,
96 | errorElement:
97 | }
98 | ])
99 |
100 | ReactDOM
101 | .createRoot(document.getElementById('root'))
102 | .render(
103 |
104 |
105 |
106 | )
107 |
--------------------------------------------------------------------------------
/xunit-viewer.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import getDescription from './src/cli/get-description.js'
4 | import getFiles from './src/cli/get-files.js'
5 | import getSuites from './src/cli/get-suites.js'
6 | import Logger from './src/cli/logger.js'
7 | import render from './src/cli/render.js'
8 | import server from './src/cli/server.js'
9 | import terminal from './src/cli/terminal.js'
10 | import watch from './src/cli/watch.js'
11 |
12 | export default async (args) => {
13 | const logger = Logger(args.noColor)
14 |
15 | const results = args.results
16 | if (!fs.existsSync(results)) {
17 | const { showHelp } = import('./src/cli/args.js')
18 | showHelp()
19 | console.log(logger.error('\n The folder/file:'), logger.file(results), logger.error('does not exist'))
20 | if (!args.script) process.exit(1)
21 | }
22 |
23 | const runXunitViewer = async () => {
24 | const files = await getFiles(logger, args)
25 | const suites = await getSuites(logger, files)
26 | const description = getDescription(suites)
27 | if (args.console) terminal(suites, logger, description, args)
28 | if (args.output !== false) {
29 | const result = render(logger, files, description, args)
30 | const outputFile = path.resolve(process.cwd(), args.output)
31 | fs.writeFileSync(outputFile, result)
32 | console.log('Written to:', logger.file(outputFile))
33 | if (!args.script && !args.server) process.exit(0)
34 | }
35 | }
36 |
37 | if (args.console || args.output !== false) await runXunitViewer()
38 | if (args.server || args.port) server(logger, args)
39 | else if (args.watch) {
40 | watch(args, async () => {
41 | await runXunitViewer()
42 | })
43 | }
44 | }
45 |
--------------------------------------------------------------------------------