├── .node-version ├── .gitattributes ├── .prettierignore ├── .github ├── codeql │ └── codeql-config.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── linter.yml │ ├── ci.yml │ ├── check-dist.yml │ └── licensed.yml ├── .vscode ├── mcp.json ├── launch.json └── settings.json ├── actionlint.yml ├── src ├── index.ts ├── __test-fixtures__ │ ├── junit-basic.xml │ ├── testcase-properties.xml │ ├── testcase-output.xml │ ├── jest-junit.xml │ ├── conventions.xml │ └── junit-complete.xml ├── metrics-generator.ts ├── metrics-submitter.ts ├── main.test.ts ├── metrics-submitter.test.ts ├── main.ts ├── metrics-generator.test.ts ├── __snapshots__ │ └── metrics-submitter.test.ts.snap ├── junit-parser.ts └── junit-parser.test.ts ├── .yaml-lint.yml ├── tsconfig.json ├── .prettierrc.yml ├── .licensed.yml ├── CODEOWNERS ├── tsconfig.eslint.json ├── .markdown-lint.yml ├── rollup.config.ts ├── tsconfig.base.json ├── badges └── coverage.svg ├── LICENSE ├── action.yml ├── jest.config.js ├── .licenses └── npm │ ├── @actions │ ├── io.dep.yml │ ├── core.dep.yml │ ├── exec.dep.yml │ └── http-client.dep.yml │ ├── @fastify │ └── busboy.dep.yml │ ├── undici.dep.yml │ └── tunnel.dep.yml ├── .devcontainer └── devcontainer.json ├── .env.example ├── .gitignore ├── eslint.config.mjs ├── README.md ├── package.json └── script └── release /.node-version: -------------------------------------------------------------------------------- 1 | 24.4.0 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | dist/** -diff linguist-generated=true 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .licenses/ 3 | dist/ 4 | node_modules/ 5 | coverage/ 6 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: JavaScript CodeQL Configuration 2 | 3 | paths-ignore: 4 | - node_modules 5 | - dist 6 | -------------------------------------------------------------------------------- /.vscode/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": { 3 | "github": { 4 | "url": "https://api.githubcopilot.com/mcp/", 5 | "type": "http" 6 | } 7 | }, 8 | "inputs": [] 9 | } 10 | -------------------------------------------------------------------------------- /actionlint.yml: -------------------------------------------------------------------------------- 1 | # See: https://github.com/rhysd/actionlint/blob/v1.7.7/docs/config.md 2 | 3 | paths: 4 | .github/workflows/**/*.{yml,yaml}: 5 | ignore: 6 | - invalid runner name "node24" 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The entrypoint for the action. This file simply imports and runs the action's 3 | * main logic. 4 | */ 5 | import { run } from './main.js' 6 | 7 | /* istanbul ignore next */ 8 | run() 9 | -------------------------------------------------------------------------------- /.yaml-lint.yml: -------------------------------------------------------------------------------- 1 | # See: https://yamllint.readthedocs.io/en/stable/ 2 | 3 | rules: 4 | document-end: disable 5 | document-start: 6 | level: warning 7 | present: false 8 | line-length: 9 | level: warning 10 | max: 80 11 | allow-non-breakable-words: true 12 | allow-non-breakable-inline-mappings: true 13 | ignore: 14 | - .licenses/ 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "outDir": "./dist" 8 | }, 9 | "exclude": ["__fixtures__", "__tests__", "coverage", "dist", "node_modules"], 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # See: https://prettier.io/docs/en/configuration 2 | 3 | printWidth: 80 4 | tabWidth: 2 5 | useTabs: false 6 | semi: false 7 | singleQuote: true 8 | quoteProps: as-needed 9 | jsxSingleQuote: false 10 | trailingComma: none 11 | bracketSpacing: true 12 | bracketSameLine: true 13 | arrowParens: always 14 | proseWrap: always 15 | htmlWhitespaceSensitivity: css 16 | endOfLine: lf 17 | -------------------------------------------------------------------------------- /.licensed.yml: -------------------------------------------------------------------------------- 1 | # See: https://github.com/licensee/licensed/blob/main/docs/configuration.md 2 | 3 | sources: 4 | npm: true 5 | 6 | allowed: 7 | - apache-2.0 8 | - bsd-2-clause 9 | - bsd-3-clause 10 | - isc 11 | - mit 12 | - cc0-1.0 13 | - other 14 | 15 | ignored: 16 | npm: 17 | # Used by Rollup.js when building in GitHub Actions 18 | - '@rollup/rollup-linux-x64-gnu' 19 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | ############################################################################ 2 | # Repository CODEOWNERS # 3 | # Order is important! The last matching pattern takes the most precedence. # 4 | ############################################################################ 5 | 6 | # Default owners, unless a later match takes precedence. 7 | * @redis-developer/clients-and-ecosystems 8 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "noEmit": true 7 | }, 8 | "exclude": ["dist", "node_modules"], 9 | "include": [ 10 | "__fixtures__", 11 | "__tests__", 12 | "src", 13 | "eslint.config.mjs", 14 | "jest.config.js", 15 | "rollup.config.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.markdown-lint.yml: -------------------------------------------------------------------------------- 1 | # See: https://github.com/DavidAnson/markdownlint 2 | 3 | # Unordered list style 4 | MD004: 5 | style: dash 6 | 7 | # Disable line length for tables 8 | MD013: 9 | tables: false 10 | 11 | # Ordered list item prefix 12 | MD029: 13 | style: one 14 | 15 | # Spaces after list markers 16 | MD030: 17 | ul_single: 1 18 | ol_single: 1 19 | ul_multi: 1 20 | ol_multi: 1 21 | 22 | # Code block style 23 | MD046: 24 | style: fenced 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Action", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "npx", 9 | "cwd": "${workspaceRoot}", 10 | "args": ["@github/local-action", ".", "src/main.ts", ".env"], 11 | "console": "integratedTerminal", 12 | "skipFiles": ["/**", "node_modules/**"] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "github.copilot.chat.reviewSelection.instructions": [ 3 | { 4 | "text": "Review the code changes carefully before accepting them." 5 | } 6 | ], 7 | "github.copilot.chat.commitMessageGeneration.instructions": [ 8 | { 9 | "text": "Use conventional commit message format." 10 | } 11 | ], 12 | "github.copilot.chat.pullRequestDescriptionGeneration.instructions": [ 13 | { "text": "Always include a list of key changes." } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | // See: https://rollupjs.org/introduction/ 2 | 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import nodeResolve from '@rollup/plugin-node-resolve' 5 | import typescript from '@rollup/plugin-typescript' 6 | 7 | const config = { 8 | input: 'src/index.ts', 9 | output: { 10 | esModule: true, 11 | file: 'dist/index.js', 12 | format: 'es', 13 | sourcemap: true 14 | }, 15 | external: [/^@opentelemetry\//], 16 | plugins: [typescript(), nodeResolve({ preferBuiltins: true }), commonjs()] 17 | } 18 | 19 | export default config 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | groups: 8 | actions-minor: 9 | update-types: 10 | - minor 11 | - patch 12 | 13 | - package-ecosystem: npm 14 | directory: / 15 | schedule: 16 | interval: weekly 17 | groups: 18 | npm-development: 19 | dependency-type: development 20 | update-types: 21 | - minor 22 | - patch 23 | npm-production: 24 | dependency-type: production 25 | update-types: 26 | - patch 27 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": false, 6 | "declarationMap": false, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "isolatedModules": true, 10 | "lib": ["ES2022"], 11 | "module": "NodeNext", 12 | "moduleResolution": "NodeNext", 13 | "newLine": "lf", 14 | "noImplicitAny": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": false, 17 | "pretty": true, 18 | "resolveJsonModule": true, 19 | "strict": true, 20 | "strictNullChecks": true, 21 | "target": "ES2022" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /badges/coverage.svg: -------------------------------------------------------------------------------- 1 | Coverage: 81.46%Coverage81.46% -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright Borislav Iv., Redis Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Otel CI Visibility 2 | description: 3 | A GitHub Action to send CI visibility data using OpenTelemetry. Metrics are 4 | being sent over OTLP/HTTP to a specified endpoint. GRPC will be supported in a 5 | future release. 6 | author: Borislav Iv., Redis Inc. 7 | 8 | branding: 9 | icon: bar-chart-2 10 | color: green 11 | 12 | inputs: 13 | junit-xml-folder: 14 | description: Path to the folder containing JUnit XML files. 15 | required: true 16 | service-name: 17 | description: OpenTelemetry service name. 18 | required: true 19 | service-version: 20 | description: OpenTelemetry service version. 21 | required: false 22 | service-namespace: 23 | description: OpenTelemetry service namespace. 24 | required: true 25 | deployment-environment: 26 | description: OpenTelemetry deployment environment. 27 | required: true 28 | default: staging 29 | otlp-endpoint: 30 | description: OpenTelemetry OTLP endpoint. 31 | required: true 32 | otlp-headers: 33 | description: OpenTelemetry OTLP headers. 34 | required: false 35 | 36 | runs: 37 | using: node24 38 | main: dist/index.js 39 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: '31 7 * * 3' 12 | 13 | permissions: 14 | actions: read 15 | checks: write 16 | contents: read 17 | security-events: write 18 | 19 | jobs: 20 | analyze: 21 | name: Analyze 22 | runs-on: ubuntu-latest 23 | 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | language: 28 | - typescript 29 | 30 | steps: 31 | - name: Checkout 32 | id: checkout 33 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 34 | with: 35 | persist-credentials: false 36 | 37 | - name: Initialize CodeQL 38 | id: initialize 39 | uses: github/codeql-action/init@d3678e237b9c32a6c9bffb3315c335f976f3549f 40 | with: 41 | config-file: .github/codeql/codeql-config.yml 42 | languages: ${{ matrix.language }} 43 | source-root: src 44 | 45 | - name: Autobuild 46 | id: autobuild 47 | uses: github/codeql-action/autobuild@d3678e237b9c32a6c9bffb3315c335f976f3549f 48 | 49 | - name: Perform CodeQL Analysis 50 | id: analyze 51 | uses: github/codeql-action/analyze@d3678e237b9c32a6c9bffb3315c335f976f3549f 52 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // See: https://jestjs.io/docs/configuration 2 | 3 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 4 | export default { 5 | clearMocks: true, 6 | collectCoverage: true, 7 | collectCoverageFrom: ['./src/**'], 8 | coverageDirectory: './coverage', 9 | coveragePathIgnorePatterns: [ 10 | '/node_modules/', 11 | '/dist/', 12 | '__snapshots__', 13 | '__test-fixtures__' 14 | ], 15 | coverageReporters: ['json-summary', 'text', 'lcov'], 16 | extensionsToTreatAsEsm: ['.ts'], 17 | moduleFileExtensions: ['ts', 'js'], 18 | preset: 'ts-jest', 19 | reporters: [ 20 | 'default', 21 | [ 22 | 'jest-junit', 23 | { 24 | outputDirectory: './test-results', 25 | outputName: 'junit.xml', 26 | ancestorSeparator: ' › ', 27 | uniqueOutputName: 'false', 28 | suiteNameTemplate: '{filepath}', 29 | classNameTemplate: '{classname}', 30 | titleTemplate: '{title}' 31 | } 32 | ] 33 | ], 34 | resolver: 'ts-jest-resolver', 35 | testEnvironment: 'node', 36 | testMatch: ['**/*.test.ts'], 37 | testPathIgnorePatterns: ['/dist/', '/node_modules/'], 38 | transform: { 39 | '^.+\\.ts$': [ 40 | 'ts-jest', 41 | { 42 | tsconfig: 'tsconfig.eslint.json', 43 | useESM: true 44 | } 45 | ] 46 | }, 47 | verbose: true 48 | } 49 | -------------------------------------------------------------------------------- /.licenses/npm/@actions/io.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "@actions/io" 3 | version: 1.1.3 4 | type: npm 5 | summary: Actions io lib 6 | homepage: https://github.com/actions/toolkit/tree/main/packages/io 7 | license: mit 8 | licenses: 9 | - sources: LICENSE.md 10 | text: |- 11 | The MIT License (MIT) 12 | 13 | Copyright 2019 GitHub 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | notices: [] 21 | -------------------------------------------------------------------------------- /.licenses/npm/@actions/core.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "@actions/core" 3 | version: 1.11.1 4 | type: npm 5 | summary: Actions core lib 6 | homepage: https://github.com/actions/toolkit/tree/main/packages/core 7 | license: mit 8 | licenses: 9 | - sources: LICENSE.md 10 | text: |- 11 | The MIT License (MIT) 12 | 13 | Copyright 2019 GitHub 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | notices: [] 21 | -------------------------------------------------------------------------------- /.licenses/npm/@actions/exec.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "@actions/exec" 3 | version: 1.1.1 4 | type: npm 5 | summary: Actions exec lib 6 | homepage: https://github.com/actions/toolkit/tree/main/packages/exec 7 | license: mit 8 | licenses: 9 | - sources: LICENSE.md 10 | text: |- 11 | The MIT License (MIT) 12 | 13 | Copyright 2019 GitHub 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | notices: [] 21 | -------------------------------------------------------------------------------- /.licenses/npm/@fastify/busboy.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "@fastify/busboy" 3 | version: 2.1.1 4 | type: npm 5 | summary: A streaming parser for HTML form data for node.js 6 | homepage: 7 | license: mit 8 | licenses: 9 | - sources: LICENSE 10 | text: |- 11 | Copyright Brian White. All rights reserved. 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to 15 | deal in the Software without restriction, including without limitation the 16 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 17 | sell copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in 21 | all copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 28 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 29 | IN THE SOFTWARE. 30 | notices: [] 31 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GitHub Actions (TypeScript)", 3 | "image": "mcr.microsoft.com/devcontainers/typescript-node:20", 4 | "postCreateCommand": "npm install", 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": ["README.md"] 8 | }, 9 | "vscode": { 10 | "extensions": [ 11 | "bierner.markdown-preview-github-styles", 12 | "davidanson.vscode-markdownlint", 13 | "dbaeumer.vscode-eslint", 14 | "esbenp.prettier-vscode", 15 | "github.copilot", 16 | "github.copilot-chat", 17 | "github.vscode-github-actions", 18 | "github.vscode-pull-request-github", 19 | "me-dutour-mathieu.vscode-github-actions", 20 | "redhat.vscode-yaml", 21 | "rvest.vs-code-prettier-eslint", 22 | "yzhang.markdown-all-in-one" 23 | ], 24 | "settings": { 25 | "editor.defaultFormatter": "esbenp.prettier-vscode", 26 | "editor.tabSize": 2, 27 | "editor.formatOnSave": true, 28 | "markdown.extension.list.indentationSize": "adaptive", 29 | "markdown.extension.italic.indicator": "_", 30 | "markdown.extension.orderedList.marker": "one" 31 | } 32 | } 33 | }, 34 | "remoteEnv": { 35 | "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" 36 | }, 37 | "features": { 38 | "ghcr.io/devcontainers/features/github-cli:1": {}, 39 | "ghcr.io/devcontainers-community/npm-features/prettier:1": {} 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.licenses/npm/undici.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: undici 3 | version: 5.28.5 4 | type: npm 5 | summary: An HTTP/1.1 client, written from scratch for Node.js 6 | homepage: https://undici.nodejs.org 7 | license: mit 8 | licenses: 9 | - sources: LICENSE 10 | text: | 11 | MIT License 12 | 13 | Copyright (c) Matteo Collina and Undici contributors 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in all 23 | copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | SOFTWARE. 32 | - sources: README.md 33 | text: MIT 34 | notices: [] 35 | -------------------------------------------------------------------------------- /.licenses/npm/@actions/http-client.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "@actions/http-client" 3 | version: 2.2.3 4 | type: npm 5 | summary: Actions Http Client 6 | homepage: https://github.com/actions/toolkit/tree/main/packages/http-client 7 | license: other 8 | licenses: 9 | - sources: LICENSE 10 | text: | 11 | Actions Http Client for Node.js 12 | 13 | Copyright (c) GitHub, Inc. 14 | 15 | All rights reserved. 16 | 17 | MIT License 18 | 19 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 20 | associated documentation files (the "Software"), to deal in the Software without restriction, 21 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 22 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 23 | subject to the following conditions: 24 | 25 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 28 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN 29 | NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 30 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 31 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 32 | notices: [] 33 | -------------------------------------------------------------------------------- /src/__test-fixtures__/junit-basic.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.licenses/npm/tunnel.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tunnel 3 | version: 0.0.6 4 | type: npm 5 | summary: Node HTTP/HTTPS Agents for tunneling proxies 6 | homepage: https://github.com/koichik/node-tunnel/ 7 | license: mit 8 | licenses: 9 | - sources: LICENSE 10 | text: | 11 | The MIT License (MIT) 12 | 13 | Copyright (c) 2012 Koichi Kobayashi 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in 23 | all copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 31 | THE SOFTWARE. 32 | - sources: README.md 33 | text: Licensed under the [MIT](https://github.com/koichik/node-tunnel/blob/master/LICENSE) 34 | license. 35 | notices: [] 36 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | # This workflow will lint the entire codebase using the 2 | # `super-linter/super-linter` action. 3 | # 4 | # For more information, see the super-linter repository: 5 | # https://github.com/super-linter/super-linter 6 | name: Lint Codebase 7 | 8 | on: 9 | pull_request: 10 | branches: 11 | - master 12 | push: 13 | branches: 14 | - master 15 | 16 | permissions: 17 | contents: read 18 | packages: read 19 | statuses: write 20 | 21 | jobs: 22 | lint: 23 | name: Lint Codebase 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - name: Checkout 28 | id: checkout 29 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 30 | with: 31 | persist-credentials: false 32 | fetch-depth: 0 33 | 34 | - name: Setup Node.js 35 | id: setup-node 36 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 37 | with: 38 | node-version-file: .node-version 39 | cache: npm 40 | 41 | - name: Install Dependencies 42 | id: install 43 | run: npm ci 44 | 45 | - name: Lint Codebase 46 | id: super-linter 47 | uses: super-linter/super-linter/slim@ffde3b2b33b745cb612d787f669ef9442b1339a6 48 | env: 49 | DEFAULT_BRANCH: master 50 | FILTER_REGEX_EXCLUDE: dist/**/* 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | LINTER_RULES_PATH: . 53 | VALIDATE_ALL_CODEBASE: true 54 | VALIDATE_JAVASCRIPT_ES: false 55 | VALIDATE_JSCPD: false 56 | VALIDATE_TYPESCRIPT_ES: false 57 | VALIDATE_JSON: false 58 | VALIDATE_NATURAL_LANGUAGE: false 59 | VALIDATE_GITHUB_ACTIONS: false 60 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # dotenv-linter:off IncorrectDelimiter 2 | 3 | # Do not commit your actual .env file to Git! This may contain secrets or other 4 | # private information. 5 | 6 | # Enable/disable step debug logging (default: `false`). For local debugging, it 7 | # may be useful to set it to `true`. 8 | ACTIONS_STEP_DEBUG=true 9 | 10 | # GitHub Actions inputs should follow `INPUT_` format (case-sensitive). 11 | # Hyphens should not be converted to underscores! 12 | #INPUT_*** = *** 13 | 14 | # GitHub Actions default environment variables. These are set for every run of a 15 | # workflow and can be used in your actions. Setting the value here will override 16 | # any value set by the local-action tool. 17 | # https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables 18 | 19 | # CI="true" 20 | # GITHUB_ACTION="" 21 | # GITHUB_ACTION_PATH="" 22 | # GITHUB_ACTION_REPOSITORY="" 23 | # GITHUB_ACTIONS="" 24 | # GITHUB_ACTOR="" 25 | # GITHUB_ACTOR_ID="" 26 | # GITHUB_API_URL="" 27 | # GITHUB_BASE_REF="" 28 | # GITHUB_ENV="" 29 | # GITHUB_EVENT_NAME="" 30 | # GITHUB_EVENT_PATH="" 31 | # GITHUB_GRAPHQL_URL="" 32 | # GITHUB_HEAD_REF="" 33 | # GITHUB_JOB="" 34 | # GITHUB_OUTPUT="" 35 | # GITHUB_PATH="" 36 | # GITHUB_REF="" 37 | # GITHUB_REF_NAME="" 38 | # GITHUB_REF_PROTECTED="" 39 | # GITHUB_REF_TYPE="" 40 | # GITHUB_REPOSITORY="" 41 | # GITHUB_REPOSITORY_ID="" 42 | # GITHUB_REPOSITORY_OWNER="" 43 | # GITHUB_REPOSITORY_OWNER_ID="" 44 | # GITHUB_RETENTION_DAYS="" 45 | # GITHUB_RUN_ATTEMPT="" 46 | # GITHUB_RUN_ID="" 47 | # GITHUB_RUN_NUMBER="" 48 | # GITHUB_SERVER_URL="" 49 | # GITHUB_SHA="" 50 | # GITHUB_STEP_SUMMARY="" 51 | # GITHUB_TRIGGERING_ACTOR="" 52 | # GITHUB_WORKFLOW="" 53 | # GITHUB_WORKFLOW_REF="" 54 | # GITHUB_WORKFLOW_SHA="" 55 | # GITHUB_WORKSPACE="" 56 | # RUNNER_ARCH="" 57 | # RUNNER_DEBUG="" 58 | # RUNNER_NAME="" 59 | # RUNNER_OS="" 60 | # RUNNER_TEMP="" 61 | # RUNNER_TOOL_CACHE="" 62 | -------------------------------------------------------------------------------- /src/__test-fixtures__/testcase-properties.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Config line #1 18 | Config line #2 19 | Config line #3 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | This text describes the purpose of this test case and provides 34 | an overview of what the test does and how it works. 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | schedule: # run every hour 11 | - cron: '0 * * * *' 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test-typescript: 18 | name: TypeScript Tests 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout 23 | id: checkout 24 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 25 | with: 26 | persist-credentials: false 27 | 28 | - name: Setup Node.js 29 | id: setup-node 30 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 31 | with: 32 | node-version-file: .node-version 33 | cache: npm 34 | 35 | - name: Install Dependencies 36 | id: npm-ci 37 | run: npm ci 38 | 39 | - name: Check Format 40 | id: npm-format-check 41 | run: npm run format:check 42 | 43 | - name: Lint 44 | id: npm-lint 45 | run: npm run lint 46 | 47 | - name: Test 48 | id: npm-ci-test 49 | run: npm run ci-test 50 | 51 | - name: Self Report Metrics 52 | id: self-report-metrics 53 | uses: ./ 54 | with: 55 | junit-xml-folder: 'test-results' 56 | service-name: 'cae-otel-ci-visibility' 57 | service-namespace: 'redis' 58 | service-version: '1.0.0-dev' 59 | deployment-environment: 'ci' 60 | otlp-endpoint: 'https://otlp-gateway-prod-us-central-0.grafana.net/otlp/v1/metrics' 61 | otlp-headers: 62 | '{"Authorization": "Basic ${{ 63 | secrets.SELF_CHECK_OTEL_AUTHORIZATION_TOKEN}}"}' 64 | env: 65 | OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf' 66 | ACTIONS_STEP_DEBUG: 'true' 67 | OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: 'BASE2_EXPONENTIAL_BUCKET_HISTOGRAM' 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # Test results 30 | test-results/ 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | .env.self-check 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # vuepress build output 85 | .vuepress/dist 86 | 87 | # Serverless directories 88 | .serverless/ 89 | 90 | # FuseBox cache 91 | .fusebox/ 92 | 93 | # DynamoDB Local files 94 | .dynamodb/ 95 | 96 | # OS metadata 97 | .DS_Store 98 | Thumbs.db 99 | 100 | # Ignore built ts files 101 | __tests__/runner/* 102 | 103 | # IDE files 104 | .idea 105 | *.code-workspace 106 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // See: https://eslint.org/docs/latest/use/configure/configuration-files 2 | 3 | import { fixupPluginRules } from '@eslint/compat' 4 | import { FlatCompat } from '@eslint/eslintrc' 5 | import js from '@eslint/js' 6 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 7 | import tsParser from '@typescript-eslint/parser' 8 | import _import from 'eslint-plugin-import' 9 | import jest from 'eslint-plugin-jest' 10 | import prettier from 'eslint-plugin-prettier' 11 | import globals from 'globals' 12 | import path from 'node:path' 13 | import { fileURLToPath } from 'node:url' 14 | 15 | const __filename = fileURLToPath(import.meta.url) 16 | const __dirname = path.dirname(__filename) 17 | const compat = new FlatCompat({ 18 | baseDirectory: __dirname, 19 | recommendedConfig: js.configs.recommended, 20 | allConfig: js.configs.all 21 | }) 22 | 23 | export default [ 24 | { 25 | ignores: ['**/coverage', '**/dist', '**/linter', '**/node_modules'] 26 | }, 27 | ...compat.extends( 28 | 'eslint:recommended', 29 | 'plugin:@typescript-eslint/eslint-recommended', 30 | 'plugin:@typescript-eslint/recommended', 31 | 'plugin:jest/recommended', 32 | 'plugin:prettier/recommended' 33 | ), 34 | { 35 | plugins: { 36 | import: fixupPluginRules(_import), 37 | jest, 38 | prettier, 39 | '@typescript-eslint': typescriptEslint 40 | }, 41 | 42 | languageOptions: { 43 | globals: { 44 | ...globals.node, 45 | ...globals.jest, 46 | Atomics: 'readonly', 47 | SharedArrayBuffer: 'readonly' 48 | }, 49 | 50 | parser: tsParser, 51 | ecmaVersion: 2023, 52 | sourceType: 'module', 53 | 54 | parserOptions: { 55 | project: ['tsconfig.eslint.json'], 56 | tsconfigRootDir: '.' 57 | } 58 | }, 59 | 60 | settings: { 61 | 'import/resolver': { 62 | typescript: { 63 | alwaysTryTypes: true, 64 | project: 'tsconfig.eslint.json' 65 | } 66 | } 67 | }, 68 | 69 | rules: { 70 | camelcase: 'off', 71 | 'eslint-comments/no-use': 'off', 72 | 'eslint-comments/no-unused-disable': 'off', 73 | 'i18n-text/no-en': 'off', 74 | 'import/no-namespace': 'off', 75 | 'no-console': 'off', 76 | 'no-shadow': 'off', 77 | 'no-unused-vars': 'off', 78 | 'prettier/prettier': 'error' 79 | } 80 | } 81 | ] 82 | -------------------------------------------------------------------------------- /.github/workflows/check-dist.yml: -------------------------------------------------------------------------------- 1 | # In TypeScript actions, `dist/` is a special directory. When you reference 2 | # an action with the `uses:` property, `dist/index.js` is the code that will be 3 | # run. For this project, the `dist/index.js` file is transpiled from other 4 | # source files. This workflow ensures the `dist/` directory contains the 5 | # expected transpiled code. 6 | # 7 | # If this workflow is run from a feature branch, it will act as an additional CI 8 | # check and fail if the checked-in `dist/` directory does not match what is 9 | # expected from the build. 10 | name: Check Transpiled JavaScript 11 | 12 | on: 13 | pull_request: 14 | branches: 15 | - master 16 | push: 17 | branches: 18 | - master 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | check-dist: 25 | name: Check dist/ 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - name: Checkout 30 | id: checkout 31 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 32 | with: 33 | persist-credentials: false 34 | 35 | - name: Setup Node.js 36 | id: setup-node 37 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 38 | with: 39 | node-version-file: .node-version 40 | cache: npm 41 | 42 | - name: Install Dependencies 43 | id: install 44 | run: npm ci 45 | 46 | - name: Build dist/ Directory 47 | id: build 48 | run: npm run bundle 49 | 50 | # This will fail the workflow if the `dist/` directory is different than 51 | # expected. 52 | - name: Compare Directories 53 | id: diff 54 | run: | 55 | if [ ! -d dist/ ]; then 56 | echo "Expected dist/ directory does not exist. See status below:" 57 | ls -la ./ 58 | exit 1 59 | fi 60 | if [ "$(git diff --ignore-space-at-eol --text dist/ | wc -l)" -gt "0" ]; then 61 | echo "Detected uncommitted changes after build. See status below:" 62 | git diff --ignore-space-at-eol --text dist/ 63 | exit 1 64 | fi 65 | 66 | # If `dist/` was different than expected, upload the expected version as a 67 | # workflow artifact. 68 | - if: ${{ failure() && steps.diff.outcome == 'failure' }} 69 | name: Upload Artifact 70 | id: upload 71 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 72 | with: 73 | name: dist 74 | path: dist/ 75 | -------------------------------------------------------------------------------- /src/__test-fixtures__/testcase-output.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Config line #1 18 | Config line #2 19 | Config line #3 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | This text describes the purpose of this test case and provides 34 | an overview of what the test does and how it works. 35 | 36 | 37 | 38 | 41 | 42 | Output line #1 43 | Output line #2 44 | 45 | [[PROPERTY|author=Adrian]] 46 | [[PROPERTY|language=english]] 47 | 48 | [[PROPERTY|browser-log]] 49 | Log line #1 50 | Log line #2 51 | Log line #3 52 | [[/PROPERTY]] 53 | 54 | [[ATTACHMENT|screenshots/dashboard.png]] 55 | [[ATTACHMENT|screenshots/users.png]] 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /.github/workflows/licensed.yml: -------------------------------------------------------------------------------- 1 | # This workflow checks the statuses of cached dependencies used in this action 2 | # with the help of the Licensed tool. If any licenses are invalid or missing, 3 | # this workflow will fail. See: https://github.com/licensee/licensed 4 | 5 | name: Licensed 6 | 7 | on: 8 | # Uncomment the below lines to run this workflow on pull requests and pushes 9 | # to the default branch. This is useful for checking licenses before merging 10 | # changes into the default branch. 11 | # pull_request: 12 | # branches: 13 | # - master 14 | # push: 15 | # branches: 16 | # - master 17 | workflow_dispatch: 18 | 19 | permissions: 20 | contents: write 21 | 22 | jobs: 23 | licensed: 24 | name: Check Licenses 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout 29 | id: checkout 30 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 31 | with: 32 | persist-credentials: false 33 | 34 | - name: Setup Node.js 35 | id: setup-node 36 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 37 | with: 38 | node-version-file: .node-version 39 | cache: npm 40 | 41 | - name: Install Dependencies 42 | id: npm-ci 43 | run: npm ci 44 | 45 | - name: Setup Ruby 46 | id: setup-ruby 47 | uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb 48 | with: 49 | ruby-version: ruby 50 | 51 | - uses: licensee/setup-licensed@0d52e575b3258417672be0dff2f115d7db8771d8 52 | with: 53 | version: 4.x 54 | github_token: ${{ secrets.GITHUB_TOKEN }} 55 | 56 | # If this is a workflow_dispatch event, update the cached licenses. 57 | - if: ${{ github.event_name == 'workflow_dispatch' }} 58 | name: Update Licenses 59 | id: update-licenses 60 | run: licensed cache 61 | 62 | # Then, commit the updated licenses to the repository. 63 | - if: ${{ github.event_name == 'workflow_dispatch' }} 64 | name: Commit Licenses 65 | id: commit-licenses 66 | run: | 67 | git config --local user.email "licensed-ci@users.noreply.github.com" 68 | git config --local user.name "licensed-ci" 69 | git add . 70 | git commit -m "Auto-update license files" 71 | git push 72 | 73 | # Last, check the status of the cached licenses. 74 | - name: Check Licenses 75 | id: check-licenses 76 | run: licensed status 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenTelemetry CI Visibility Action 2 | 3 | Extracts test metrics from JUnit XML files and sends them to OTLP endpoints. 4 | 5 | ## What it does 6 | 7 | - Reads JUnit XML files from a directory 8 | - Parses test results (passed/failed/skipped/errors) 9 | - Generates OpenTelemetry metrics with proper semantic conventions 10 | - Ships metrics to OTLP-compatible backends 11 | 12 | ## Usage 13 | 14 | ```yaml 15 | - uses: redis-developer/cae-otel-ci-visibility@v1 16 | with: 17 | junit-xml-folder: './test-results' 18 | service-name: 'my-service' 19 | service-namespace: 'my-team' 20 | service-version: 'v1.2.3' 21 | deployment-environment: 'ci' 22 | otlp-endpoint: 'https://otlp.example.com/v1/metrics' 23 | otlp-headers: 'authorization=Bearer ${{ secrets.OTLP_TOKEN }}' 24 | ``` 25 | 26 | ## Inputs 27 | 28 | | Input | Required | Default | Description | 29 | | ------------------------ | -------- | --------- | -------------------------------------------- | 30 | | `junit-xml-folder` | yes | - | Path to directory containing JUnit XML files | 31 | | `service-name` | yes | - | OpenTelemetry service name | 32 | | `service-namespace` | yes | - | OpenTelemetry service namespace | 33 | | `deployment-environment` | yes | `staging` | Deployment environment | 34 | | `otlp-endpoint` | yes | - | OTLP metrics endpoint URL | 35 | | `service-version` | no | git SHA | Service version | 36 | | `otlp-headers` | no | - | OTLP headers (key=value,key2=value2 or JSON) | 37 | 38 | ## Metrics 39 | 40 | Generates standard test metrics: 41 | 42 | - `test.duration` - Individual test execution time 43 | - `test.status` - Test execution count by status 44 | - `test.suite.duration` - Test suite execution time 45 | - `test.suite.total` - Test count per suite by status 46 | - `test.failure` - Test failures by type 47 | - `test.error` - Test errors by type 48 | 49 | All metrics include proper OpenTelemetry semantic conventions and CI context. 50 | 51 | ## Requirements 52 | 53 | - JUnit XML files 54 | - OTLP-compatible metrics backend 55 | - Node.js 24+ runtime 56 | 57 | ## Notes 58 | 59 | - Processes all `.xml` files in the specified directory 60 | - Combines multiple XML files into a single report 61 | - Handles malformed XML gracefully 62 | - No outputs - metrics are the deliverable 63 | 64 | Built for engineers who want observability without ceremony. 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "otel-ci-visibility-action", 3 | "description": "GitHub Action to collect OpenTelemetry metrics for CI visibility", 4 | "version": "1.0.1", 5 | "author": "Borislav Iv. Redis Inc.", 6 | "type": "module", 7 | "private": true, 8 | "homepage": "https://github.com/redis-developer/cae-otel-ci-visibility", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/redis-developer/cae-otel-ci-visibility.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/redis-developer/cae-otel-ci-visibility/issues" 15 | }, 16 | "keywords": [ 17 | "actions", 18 | "opentelemetry", 19 | "ci", 20 | "visibility", 21 | "junit", 22 | "testing", 23 | "metrics", 24 | "otlp" 25 | ], 26 | "exports": { 27 | ".": "./dist/index.js" 28 | }, 29 | "engines": { 30 | "node": ">=24.0.0" 31 | }, 32 | "scripts": { 33 | "bundle": "npm run format:write && npm run package", 34 | "ci-test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest", 35 | "coverage": "npx make-coverage-badge --output-path ./badges/coverage.svg", 36 | "format:write": "npx prettier --write .", 37 | "format:check": "npx prettier --check .", 38 | "lint": "npx eslint .", 39 | "local-action": "npx @github/local-action . src/main.ts .env", 40 | "package": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript", 41 | "package:watch": "npm run package -- --watch", 42 | "test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest", 43 | "all": "npm run format:write && npm run lint && npm run test && npm run coverage && npm run package", 44 | "typecheck": "tsc --noEmit", 45 | "test:update:snapshot": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest --updateSnapshot" 46 | }, 47 | "license": "MIT", 48 | "dependencies": { 49 | "@actions/core": "1.11.1", 50 | "@actions/github": "6.0.1", 51 | "@opentelemetry/api": "^1.9.0", 52 | "@opentelemetry/auto-instrumentations-node": "^0.64.1", 53 | "@opentelemetry/exporter-metrics-otlp-proto": "^0.205.0", 54 | "@opentelemetry/resources": "^2.1.0", 55 | "@opentelemetry/sdk-metrics": "^2.1.0", 56 | "@opentelemetry/sdk-node": "^0.205.0", 57 | "@opentelemetry/semantic-conventions": "^1.36.0", 58 | "fast-xml-parser": "^5.2.5" 59 | }, 60 | "devDependencies": { 61 | "@eslint/compat": "^1.3.1", 62 | "@github/local-action": "^5.0.0", 63 | "@jest/globals": "^30.0.5", 64 | "@rollup/plugin-commonjs": "^28.0.6", 65 | "@rollup/plugin-node-resolve": "^16.0.1", 66 | "@rollup/plugin-typescript": "^12.1.4", 67 | "@types/jest": "^30.0.0", 68 | "@types/node": "^24.2.0", 69 | "@typescript-eslint/eslint-plugin": "^8.39.0", 70 | "@typescript-eslint/parser": "^8.32.1", 71 | "eslint": "^9.32.0", 72 | "eslint-config-prettier": "^10.1.8", 73 | "eslint-import-resolver-typescript": "^4.4.4", 74 | "eslint-plugin-import": "^2.32.0", 75 | "eslint-plugin-jest": "^29.0.1", 76 | "eslint-plugin-prettier": "^5.5.3", 77 | "jest": "^30.0.5", 78 | "jest-junit": "^16.0.0", 79 | "make-coverage-badge": "^1.2.0", 80 | "prettier": "^3.6.2", 81 | "prettier-eslint": "^16.4.2", 82 | "rollup": "^4.46.2", 83 | "ts-jest": "^29.4.1", 84 | "ts-jest-resolver": "^2.0.1", 85 | "typescript": "^5.9.2" 86 | }, 87 | "optionalDependencies": { 88 | "@rollup/rollup-linux-x64-gnu": "*" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/metrics-generator.ts: -------------------------------------------------------------------------------- 1 | import type { TJUnitReport, TSuite, TTest } from './junit-parser.js' 2 | 3 | export interface TMetricsConfig { 4 | readonly serviceName: string 5 | readonly serviceNamespace?: string 6 | readonly serviceVersion: string | undefined 7 | readonly environment: string | undefined 8 | readonly repository: string | undefined 9 | readonly branch: string | undefined 10 | readonly commitSha: string | undefined 11 | readonly runId: string | undefined 12 | readonly jobUUID: string | undefined 13 | } 14 | 15 | export interface TMetricDataPoint { 16 | readonly metricName: string 17 | readonly metricType: 'histogram' | 'counter' | 'updowncounter' | 'gauge' 18 | readonly value: number 19 | readonly attributes: Readonly> 20 | readonly description: string 21 | readonly unit: string | undefined 22 | } 23 | 24 | export const generateMetrics = ( 25 | report: TJUnitReport, 26 | config: TMetricsConfig 27 | ): readonly TMetricDataPoint[] => { 28 | const metrics: TMetricDataPoint[] = [] 29 | const baseAttributes = getBaseAttributes(config) 30 | 31 | for (const suite of report.testsuites) { 32 | metrics.push(...generateSuiteMetrics(suite, baseAttributes)) 33 | } 34 | 35 | return metrics 36 | } 37 | 38 | const generateSuiteMetrics = ( 39 | suite: TSuite, 40 | baseAttributes: Readonly> 41 | ): readonly TMetricDataPoint[] => { 42 | const metrics: TMetricDataPoint[] = [] 43 | 44 | const suiteAttributes = { 45 | ...baseAttributes, 46 | 'test.suite.name': suite.name, 47 | 'test.framework': 'junit' 48 | } 49 | 50 | for (const testCase of suite.tests) { 51 | metrics.push(...generateTestCaseMetrics(testCase, suiteAttributes)) 52 | } 53 | 54 | if (suite.suites) { 55 | for (const nestedSuite of suite.suites) { 56 | metrics.push(...generateSuiteMetrics(nestedSuite, baseAttributes)) 57 | } 58 | } 59 | 60 | return metrics 61 | } 62 | 63 | const generateTestCaseMetrics = ( 64 | testCase: TTest, 65 | suiteAttributes: Readonly> 66 | ): readonly TMetricDataPoint[] => { 67 | const metrics: TMetricDataPoint[] = [] 68 | 69 | const testAttributes = { 70 | ...suiteAttributes, 71 | 'test.name': testCase.name, 72 | 'test.class.name': testCase.classname, 73 | 'test.status': testCase.result.status 74 | } 75 | 76 | // Only metric: test duration as a gauge for performance regression detection 77 | metrics.push({ 78 | metricName: 'test_duration_seconds', 79 | metricType: 'gauge', 80 | value: testCase.time, 81 | attributes: testAttributes, 82 | description: 83 | 'Individual test execution duration for performance regression detection', 84 | unit: 's' 85 | }) 86 | 87 | return metrics 88 | } 89 | 90 | const getBaseAttributes = ( 91 | config: TMetricsConfig 92 | ): Readonly> => { 93 | const attributes: Record = { 94 | 'service.name': config.serviceName 95 | } 96 | 97 | if (config.serviceNamespace) 98 | attributes['service.namespace'] = config.serviceNamespace 99 | if (config.serviceVersion) 100 | attributes['service.version'] = config.serviceVersion 101 | if (config.environment) 102 | attributes['deployment.environment'] = config.environment 103 | if (config.repository) attributes['vcs.repository.name'] = config.repository 104 | if (config.branch) attributes['vcs.repository.ref.name'] = config.branch 105 | if (config.commitSha) 106 | attributes['vcs.repository.ref.revision'] = config.commitSha 107 | if (config.runId) { 108 | attributes['ci.run.id'] = config.runId 109 | } 110 | 111 | if (config.jobUUID) { 112 | attributes['ci.job.id'] = config.jobUUID 113 | } 114 | 115 | return attributes 116 | } 117 | -------------------------------------------------------------------------------- /src/metrics-submitter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | metrics, 3 | type MetricOptions, 4 | type Histogram, 5 | type Counter, 6 | type UpDownCounter, 7 | type Gauge 8 | } from '@opentelemetry/api' 9 | import type { MeterProvider } from '@opentelemetry/sdk-metrics' 10 | import type { TMetricDataPoint, TMetricsConfig } from './metrics-generator.js' 11 | 12 | type TMetric = Histogram | Counter | UpDownCounter | Gauge 13 | 14 | export class MetricsSubmitter { 15 | private readonly meter 16 | private readonly histograms = new Map() 17 | private readonly counters = new Map() 18 | private readonly upDownCounters = new Map() 19 | private readonly gauges = new Map() 20 | private readonly namespace 21 | private readonly version 22 | 23 | constructor( 24 | config: TMetricsConfig, 25 | meterProvider: MeterProvider | undefined, 26 | namespace: string, 27 | version: string 28 | ) { 29 | if (meterProvider) { 30 | metrics.disable() 31 | metrics.setGlobalMeterProvider(meterProvider) 32 | } 33 | 34 | this.namespace = namespace 35 | this.version = version 36 | this.meter = metrics.getMeter(config.serviceName, config.serviceVersion) 37 | } 38 | 39 | public submitMetrics(metricDataPoints: readonly TMetricDataPoint[]): void { 40 | for (const dataPoint of metricDataPoints) { 41 | switch (dataPoint.metricType) { 42 | case 'histogram': 43 | this.recordHistogram(dataPoint) 44 | break 45 | case 'counter': 46 | this.incrementCounter(dataPoint) 47 | break 48 | case 'updowncounter': 49 | this.updateUpDownCounter(dataPoint) 50 | break 51 | case 'gauge': 52 | this.recordGauge(dataPoint) 53 | break 54 | } 55 | } 56 | } 57 | 58 | private getOrCreateMetric( 59 | metricName: string, 60 | metricMap: Map, 61 | createMetric: () => T 62 | ): T { 63 | if (!metricMap.has(metricName)) { 64 | metricMap.set(metricName, createMetric()) 65 | } 66 | 67 | const metric = metricMap.get(metricName) 68 | if (!metric) { 69 | throw new Error(`Not found: ${metricName}`) 70 | } 71 | return metric 72 | } 73 | 74 | private createHistogramOptions(dataPoint: TMetricDataPoint): MetricOptions { 75 | const options: MetricOptions = { 76 | description: dataPoint.description, 77 | unit: dataPoint.unit 78 | } 79 | 80 | return options 81 | } 82 | 83 | private recordHistogram(dataPoint: TMetricDataPoint): void { 84 | const histogram = this.getOrCreateMetric( 85 | dataPoint.metricName, 86 | this.histograms, 87 | () => 88 | this.meter.createHistogram( 89 | `${this.namespace}.${this.version}.${dataPoint.metricName}`, 90 | this.createHistogramOptions(dataPoint) 91 | ) 92 | ) 93 | 94 | histogram.record(dataPoint.value, dataPoint.attributes) 95 | } 96 | 97 | private incrementCounter(dataPoint: TMetricDataPoint): void { 98 | const counter = this.getOrCreateMetric( 99 | dataPoint.metricName, 100 | this.counters, 101 | () => 102 | this.meter.createCounter( 103 | `${this.namespace}.${this.version}.${dataPoint.metricName}`, 104 | { 105 | description: dataPoint.description, 106 | unit: dataPoint.unit 107 | } 108 | ) 109 | ) 110 | 111 | counter.add(dataPoint.value, dataPoint.attributes) 112 | } 113 | 114 | private updateUpDownCounter(dataPoint: TMetricDataPoint): void { 115 | const upDownCounter = this.getOrCreateMetric( 116 | dataPoint.metricName, 117 | this.upDownCounters, 118 | () => 119 | this.meter.createUpDownCounter( 120 | `${this.namespace}.${this.version}.${dataPoint.metricName}`, 121 | { 122 | description: dataPoint.description, 123 | unit: dataPoint.unit 124 | } 125 | ) 126 | ) 127 | 128 | upDownCounter.add(dataPoint.value, dataPoint.attributes) 129 | } 130 | 131 | private recordGauge(dataPoint: TMetricDataPoint): void { 132 | const gauge = this.getOrCreateMetric( 133 | dataPoint.metricName, 134 | this.gauges, 135 | () => 136 | this.meter.createGauge( 137 | `${this.namespace}.${this.version}.${dataPoint.metricName}`, 138 | { 139 | description: dataPoint.description, 140 | unit: dataPoint.unit 141 | } 142 | ) 143 | ) 144 | 145 | gauge.record(dataPoint.value, dataPoint.attributes) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/__test-fixtures__/jest-junit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /src/__test-fixtures__/conventions.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | This text describes the purpose of this test case and provides 20 | an overview of what the test does and how it works. 21 | 22 | 23 | 26 | 27 | 28 | Title

Text

]]> 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | This is a more complex test step with multiple lines 50 | of text using a property with a text value. 51 | 52 | 53 | 55 | 56 | 57 | 58 | 59 | 60 | 62 | 64 | This line describes this step. You can also use HTML. 65 | 66 | 67 | You can optionally include more sub fields, e.g. for the expected results. 68 | 69 | ]]> 70 |
71 | 72 | 76 | Title 91 |

This is an HTML example.

92 | [[/PROPERTY]] 93 | 94 | [[ATTACHMENT|screenshots/dashboard.png]] 95 | [[ATTACHMENT|screenshots/users.png]]]]> 96 |
97 |
98 |
99 |
100 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit early 4 | # See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin 5 | set -e 6 | 7 | # About: 8 | # 9 | # This is a helper script to tag and push a new release. GitHub Actions use 10 | # release tags to allow users to select a specific version of the action to use. 11 | # 12 | # See: https://github.com/actions/typescript-action#publishing-a-new-release 13 | # See: https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations 14 | # 15 | # This script will do the following: 16 | # 17 | # 1. Retrieve the latest release tag 18 | # 2. Display the latest release tag 19 | # 3. Prompt the user for a new release tag 20 | # 4. Validate the new release tag 21 | # 5. Remind user to update the version field in package.json 22 | # 6. Tag a new release 23 | # 7. Set 'is_major_release' variable 24 | # 8. Point separate major release tag (e.g. v1, v2) to the new release 25 | # 9. Push the new tags (with commits, if any) to remote 26 | # 10. If this is a major release, create a 'releases/v#' branch and push 27 | # 28 | # Usage: 29 | # 30 | # script/release 31 | 32 | # Variables 33 | semver_tag_regex='v[0-9]+\.[0-9]+\.[0-9]+$' 34 | semver_tag_glob='v[0-9].[0-9].[0-9]*' 35 | git_remote='origin' 36 | major_semver_tag_regex='\(v[0-9]*\)' 37 | 38 | # Terminal colors 39 | OFF='\033[0m' 40 | BOLD_RED='\033[1;31m' 41 | BOLD_GREEN='\033[1;32m' 42 | BOLD_BLUE='\033[1;34m' 43 | BOLD_PURPLE='\033[1;35m' 44 | BOLD_UNDERLINED='\033[1;4m' 45 | BOLD='\033[1m' 46 | 47 | # 1. Retrieve the latest release tag 48 | if ! latest_tag=$(git describe --abbrev=0 --match="$semver_tag_glob"); then 49 | # There are no existing release tags 50 | echo -e "No tags found (yet) - Continue to create and push your first tag" 51 | latest_tag="[unknown]" 52 | fi 53 | 54 | # 2. Display the latest release tag 55 | echo -e "The latest release tag is: ${BOLD_BLUE}${latest_tag}${OFF}" 56 | 57 | # 3. Prompt the user for a new release tag 58 | read -r -p 'Enter a new release tag (vX.X.X format): ' new_tag 59 | 60 | # 4. Validate the new release tag 61 | if echo "$new_tag" | grep -q -E "$semver_tag_regex"; then 62 | # Release tag is valid 63 | echo -e "Tag: ${BOLD_BLUE}$new_tag${OFF} is valid syntax" 64 | else 65 | # Release tag is not in `vX.X.X` format 66 | echo -e "Tag: ${BOLD_BLUE}$new_tag${OFF} is ${BOLD_RED}not valid${OFF} (must be in ${BOLD}vX.X.X${OFF} format)" 67 | exit 1 68 | fi 69 | 70 | # 5. Remind user to update the version field in package.json 71 | echo -e -n "Make sure the version field in package.json is ${BOLD_BLUE}$new_tag${OFF}. Yes? [Y/${BOLD_UNDERLINED}n${OFF}] " 72 | read -r YN 73 | 74 | if [[ ! ($YN == "y" || $YN == "Y") ]]; then 75 | # Package.json version field is not up to date 76 | echo -e "Please update the package.json version to ${BOLD_PURPLE}$new_tag${OFF} and commit your changes" 77 | exit 1 78 | fi 79 | 80 | # 6. Tag a new release 81 | git tag "$new_tag" --annotate --message "$new_tag Release" 82 | echo -e "Tagged: ${BOLD_GREEN}$new_tag${OFF}" 83 | 84 | # 7. Set 'is_major_release' variable 85 | new_major_release_tag=$(expr "$new_tag" : "$major_semver_tag_regex") 86 | 87 | if [[ "$latest_tag" = "[unknown]" ]]; then 88 | # This is the first major release 89 | is_major_release='yes' 90 | else 91 | # Compare the major version of the latest tag with the new tag 92 | latest_major_release_tag=$(expr "$latest_tag" : "$major_semver_tag_regex") 93 | 94 | if ! [[ "$new_major_release_tag" = "$latest_major_release_tag" ]]; then 95 | is_major_release='yes' 96 | else 97 | is_major_release='no' 98 | fi 99 | fi 100 | 101 | # 8. Point separate major release tag (e.g. v1, v2) to the new release 102 | if [ $is_major_release = 'yes' ]; then 103 | # Create a new major version tag and point it to this release 104 | git tag "$new_major_release_tag" --annotate --message "$new_major_release_tag Release" 105 | echo -e "New major version tag: ${BOLD_GREEN}$new_major_release_tag${OFF}" 106 | else 107 | # Update the major version tag to point it to this release 108 | git tag "$latest_major_release_tag" --force --annotate --message "Sync $latest_major_release_tag tag with $new_tag" 109 | echo -e "Synced ${BOLD_GREEN}$latest_major_release_tag${OFF} with ${BOLD_GREEN}$new_tag${OFF}" 110 | fi 111 | 112 | # 9. Push the new tags (with commits, if any) to remote 113 | git push --follow-tags 114 | 115 | if [ $is_major_release = 'yes' ]; then 116 | # New major version tag is pushed with the '--follow-tags' flags 117 | echo -e "Tags: ${BOLD_GREEN}$new_major_release_tag${OFF} and ${BOLD_GREEN}$new_tag${OFF} pushed to remote" 118 | else 119 | # Force push the updated major version tag 120 | git push $git_remote "$latest_major_release_tag" --force 121 | echo -e "Tags: ${BOLD_GREEN}$latest_major_release_tag${OFF} and ${BOLD_GREEN}$new_tag${OFF} pushed to remote" 122 | fi 123 | 124 | # 10. If this is a major release, create a 'releases/v#' branch and push 125 | if [ $is_major_release = 'yes' ]; then 126 | git branch "releases/$new_major_release_tag" "$new_major_release_tag" 127 | echo -e "Branch: ${BOLD_BLUE}releases/$new_major_release_tag${OFF} created from ${BOLD_BLUE}$new_major_release_tag${OFF} tag" 128 | git push --set-upstream $git_remote "releases/$new_major_release_tag" 129 | echo -e "Branch: ${BOLD_GREEN}releases/$new_major_release_tag${OFF} pushed to remote" 130 | fi 131 | 132 | # Completed 133 | echo -e "${BOLD_GREEN}Done!${OFF}" 134 | -------------------------------------------------------------------------------- /src/main.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { mkdtempSync, writeFileSync, rmSync, readFileSync, mkdirSync } from 'fs' 3 | import { tmpdir } from 'os' 4 | import { join } from 'path' 5 | 6 | const mockCore = { 7 | getInput: jest.fn(), 8 | info: jest.fn(), 9 | warning: jest.fn(), 10 | error: jest.fn(), 11 | setFailed: jest.fn() 12 | } 13 | 14 | const mockGithub = { 15 | context: { 16 | repo: { owner: 'testowner', repo: 'testrepo' }, 17 | ref: 'refs/heads/main', 18 | sha: 'abc123def456', 19 | runId: 12345, 20 | runNumber: 42, 21 | workflow: 'CI', 22 | actor: 'testuser', 23 | eventName: 'push' 24 | } 25 | } 26 | 27 | const mockMeterProvider = { 28 | forceFlush: jest.fn().mockResolvedValue(undefined as never) 29 | } 30 | 31 | const mockOpenTelemetry = { 32 | MeterProvider: jest.fn(() => mockMeterProvider), 33 | PeriodicExportingMetricReader: jest.fn(() => ({})), 34 | ConsoleMetricExporter: jest.fn(() => ({})), 35 | AggregationType: { 36 | EXPONENTIAL_HISTOGRAM: 5 37 | } 38 | } 39 | 40 | const mockOTLPExporter = { 41 | OTLPMetricExporter: jest.fn(() => ({})) 42 | } 43 | 44 | const mockResources = { 45 | resourceFromAttributes: jest.fn(() => ({})) 46 | } 47 | 48 | const mockSemanticConventions = { 49 | ATTR_SERVICE_NAME: 'service.name', 50 | ATTR_SERVICE_VERSION: 'service.version', 51 | SEMRESATTRS_SERVICE_NAMESPACE: 'service.namespace', 52 | SEMRESATTRS_DEPLOYMENT_ENVIRONMENT: 'deployment.environment' 53 | } 54 | 55 | const mockMetricsSubmitter = { 56 | submitMetrics: jest.fn() 57 | } 58 | 59 | jest.unstable_mockModule('@actions/core', () => mockCore) 60 | jest.unstable_mockModule('@actions/github', () => mockGithub) 61 | jest.unstable_mockModule('@opentelemetry/sdk-metrics', () => mockOpenTelemetry) 62 | jest.unstable_mockModule( 63 | '@opentelemetry/exporter-metrics-otlp-proto', 64 | () => mockOTLPExporter 65 | ) 66 | jest.unstable_mockModule('@opentelemetry/resources', () => mockResources) 67 | jest.unstable_mockModule( 68 | '@opentelemetry/semantic-conventions', 69 | () => mockSemanticConventions 70 | ) 71 | jest.unstable_mockModule('./metrics-submitter.js', () => ({ 72 | MetricsSubmitter: jest.fn(() => mockMetricsSubmitter) 73 | })) 74 | 75 | const { run } = await import('./main.js') 76 | 77 | describe('main.ts', () => { 78 | let testDir: string 79 | let junitXmlContent: string 80 | 81 | beforeAll(() => { 82 | junitXmlContent = readFileSync( 83 | 'src/__test-fixtures__/junit-basic.xml', 84 | 'utf-8' 85 | ) 86 | }) 87 | 88 | beforeEach(() => { 89 | testDir = mkdtempSync(join(tmpdir(), 'junit-test-')) 90 | jest.clearAllMocks() 91 | }) 92 | 93 | afterEach(() => { 94 | rmSync(testDir, { recursive: true, force: true }) 95 | }) 96 | 97 | it('should correctly parse action.yml inputs and configure OpenTelemetry', async () => { 98 | writeFileSync(join(testDir, 'test-results.xml'), junitXmlContent) 99 | 100 | mockCore.getInput.mockImplementation( 101 | //@ts-expect-error - Mock implementation 102 | (name: string) => { 103 | switch (name) { 104 | case 'junit-xml-folder': 105 | return testDir 106 | case 'service-name': 107 | return 'test-service' 108 | case 'service-namespace': 109 | return 'test-namespace' 110 | case 'service-version': 111 | return 'v1.0.0' 112 | case 'deployment-environment': 113 | return 'production' 114 | case 'otlp-endpoint': 115 | return 'http://localhost:4318/v1/metrics' 116 | case 'otlp-headers': 117 | return 'api-key=secret123,x-tenant=test' 118 | case 'otlp-protocol': 119 | return 'http/protobuf' 120 | default: 121 | return '' 122 | } 123 | } 124 | ) 125 | 126 | await run() 127 | 128 | expect(mockCore.getInput).toHaveBeenCalledWith('junit-xml-folder', { 129 | required: true 130 | }) 131 | expect(mockCore.getInput).toHaveBeenCalledWith('service-name', { 132 | required: true 133 | }) 134 | expect(mockCore.getInput).toHaveBeenCalledWith('service-namespace', { 135 | required: true 136 | }) 137 | expect(mockCore.getInput).toHaveBeenCalledWith('deployment-environment') 138 | expect(mockCore.getInput).toHaveBeenCalledWith('otlp-endpoint', { 139 | required: true 140 | }) 141 | 142 | expect(mockResources.resourceFromAttributes).toHaveBeenCalledWith({ 143 | 'service.name': 'test-service', 144 | 'service.namespace': 'test-namespace', 145 | 'service.version': 'v1.0.0', 146 | 'deployment.environment.name': 'production' 147 | }) 148 | 149 | expect(mockOTLPExporter.OTLPMetricExporter).toHaveBeenCalledWith({ 150 | url: 'http://localhost:4318/v1/metrics', 151 | headers: { 152 | 'api-key': 'secret123', 153 | 'x-tenant': 'test' 154 | }, 155 | timeoutMillis: 30000, 156 | temporalityPreference: 1 // AggregationTemporalityPreference.CUMULATIVE 157 | }) 158 | 159 | expect(mockCore.info).toHaveBeenCalledWith( 160 | '✅ CI visibility metrics submitted successfully' 161 | ) 162 | }) 163 | 164 | it('should process JUnit XML files and submit test metrics', async () => { 165 | writeFileSync(join(testDir, 'test-results.xml'), junitXmlContent) 166 | 167 | mockCore.getInput.mockImplementation( 168 | //@ts-expect-error - Mock implementation 169 | (name: string) => { 170 | switch (name) { 171 | case 'junit-xml-folder': 172 | return testDir 173 | case 'service-name': 174 | return 'test-service' 175 | case 'service-namespace': 176 | return 'test-namespace' 177 | case 'deployment-environment': 178 | return 'staging' 179 | case 'otlp-endpoint': 180 | return 'http://localhost:4318/v1/metrics' 181 | default: 182 | return '' 183 | } 184 | } 185 | ) 186 | 187 | await run() 188 | 189 | expect(mockCore.info).toHaveBeenCalledWith( 190 | `📊 Processing JUnit XML files from: ${testDir}` 191 | ) 192 | 193 | expect(mockMetricsSubmitter.submitMetrics).toHaveBeenCalledTimes(1) 194 | expect(mockMeterProvider.forceFlush).toHaveBeenCalled() 195 | expect(mockCore.info).toHaveBeenCalledWith( 196 | '✅ CI visibility metrics submitted successfully' 197 | ) 198 | }) 199 | 200 | it('should handle empty XML folder gracefully', async () => { 201 | mkdirSync(testDir, { recursive: true }) 202 | 203 | mockCore.getInput.mockImplementation( 204 | //@ts-expect-error - Mock implementation 205 | (name: string) => { 206 | switch (name) { 207 | case 'junit-xml-folder': 208 | return testDir 209 | case 'service-name': 210 | return 'test-service' 211 | case 'service-namespace': 212 | return 'test-namespace' 213 | case 'deployment-environment': 214 | return 'staging' 215 | case 'otlp-endpoint': 216 | return 'http://localhost:4318/v1/metrics' 217 | default: 218 | return '' 219 | } 220 | } 221 | ) 222 | 223 | await run() 224 | 225 | expect(mockCore.warning).toHaveBeenCalledWith( 226 | `No test suites found in ${testDir}` 227 | ) 228 | expect(mockMetricsSubmitter.submitMetrics).not.toHaveBeenCalled() 229 | }) 230 | }) 231 | -------------------------------------------------------------------------------- /src/metrics-submitter.test.ts: -------------------------------------------------------------------------------- 1 | import { MetricsSubmitter } from './metrics-submitter.js' 2 | import type { TMetricDataPoint, TMetricsConfig } from './metrics-generator.js' 3 | import { MeterProvider } from '@opentelemetry/sdk-metrics' 4 | import { 5 | InMemoryMetricExporter, 6 | ResourceMetrics 7 | } from '@opentelemetry/sdk-metrics' 8 | 9 | import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' 10 | import { AggregationTemporality } from '@opentelemetry/sdk-metrics' 11 | import { resourceFromAttributes } from '@opentelemetry/resources' 12 | import { setTimeout } from 'timers/promises' 13 | 14 | const testResource = resourceFromAttributes({ 15 | 'service.name': 'test-service', 16 | 'service.version': '1.0.0' 17 | }) 18 | 19 | async function waitForNumberOfExports( 20 | exporter: InMemoryMetricExporter, 21 | numberOfExports: number 22 | ): Promise { 23 | if (numberOfExports <= 0) { 24 | throw new Error('numberOfExports must be greater than or equal to 0') 25 | } 26 | 27 | let totalExports = 0 28 | let attempts = 0 29 | const maxAttempts = 50 30 | 31 | while (totalExports < numberOfExports && attempts < maxAttempts) { 32 | await setTimeout(20) 33 | const exportedMetrics = exporter.getMetrics() 34 | totalExports = exportedMetrics.length 35 | attempts++ 36 | } 37 | 38 | if (attempts >= maxAttempts) { 39 | throw new Error( 40 | `Timeout waiting for ${numberOfExports} exports after ${maxAttempts} attempts` 41 | ) 42 | } 43 | return exporter.getMetrics() 44 | } 45 | 46 | function normalizeMetricsForSnapshot( 47 | exportedMetrics: ResourceMetrics[] 48 | ): unknown[] { 49 | return exportedMetrics.map((resourceMetric) => ({ 50 | ...resourceMetric, 51 | scopeMetrics: resourceMetric.scopeMetrics.map((scopeMetric) => ({ 52 | ...scopeMetric, 53 | metrics: scopeMetric.metrics.map((metric) => ({ 54 | ...metric, 55 | dataPoints: metric.dataPoints.map((dataPoint) => { 56 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 57 | const { endTime, startTime, ...rest } = dataPoint 58 | 59 | return rest 60 | }) 61 | })) 62 | })) 63 | })) 64 | } 65 | 66 | type TTestCase = { 67 | name: string 68 | metrics: readonly TMetricDataPoint[] 69 | expectExports?: number 70 | } 71 | 72 | describe('MetricsSubmitter', () => { 73 | let exporter: InMemoryMetricExporter 74 | let meterProvider: MeterProvider 75 | let metricReader: PeriodicExportingMetricReader 76 | let submitter: MetricsSubmitter 77 | 78 | const config: TMetricsConfig = { 79 | serviceName: 'test-service', 80 | serviceVersion: '1.0.0', 81 | environment: undefined, 82 | repository: undefined, 83 | branch: undefined, 84 | commitSha: undefined, 85 | runId: undefined, 86 | jobUUID: undefined 87 | } 88 | 89 | const createDataPoint = ( 90 | overrides: Partial = {} 91 | ): TMetricDataPoint => ({ 92 | metricName: 'test.metric', 93 | metricType: 'counter', 94 | value: 1, 95 | attributes: { 'test.name': 'example' }, 96 | description: 'Test metric', 97 | unit: '{test}', 98 | ...overrides 99 | }) 100 | 101 | beforeEach(() => { 102 | exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE) 103 | 104 | metricReader = new PeriodicExportingMetricReader({ 105 | exporter: exporter, 106 | exportIntervalMillis: 100, 107 | exportTimeoutMillis: 50 108 | }) 109 | 110 | meterProvider = new MeterProvider({ 111 | resource: testResource, 112 | readers: [metricReader] 113 | }) 114 | submitter = new MetricsSubmitter( 115 | config, 116 | meterProvider, 117 | 'test-namespace', 118 | 'v1' 119 | ) 120 | }) 121 | 122 | afterEach(async () => { 123 | await exporter.shutdown() 124 | await metricReader.shutdown() 125 | }) 126 | 127 | const testCases: readonly TTestCase[] = [ 128 | { 129 | name: 'creates instruments for each metric type', 130 | metrics: [ 131 | createDataPoint({ 132 | metricName: 'test.duration', 133 | metricType: 'histogram', 134 | unit: 's' 135 | }), 136 | createDataPoint({ 137 | metricName: 'test.status', 138 | metricType: 'counter', 139 | unit: '{test}' 140 | }), 141 | createDataPoint({ 142 | metricName: 'test.suite.total', 143 | metricType: 'updowncounter', 144 | unit: '{test}' 145 | }) 146 | ] 147 | }, 148 | { 149 | name: 'configures histogram with explicit buckets', 150 | metrics: [ 151 | createDataPoint({ 152 | metricName: 'test.duration', 153 | metricType: 'histogram' 154 | }) 155 | ] 156 | }, 157 | { 158 | name: 'reuses existing instruments for same metric name', 159 | metrics: [ 160 | createDataPoint({ metricName: 'test.counter', value: 1 }), 161 | createDataPoint({ metricName: 'test.counter', value: 2 }) 162 | ] 163 | }, 164 | { 165 | name: 'handles multiple metric types in one submission', 166 | metrics: [ 167 | createDataPoint({ 168 | metricName: 'test.histogram', 169 | metricType: 'histogram', 170 | value: 42 171 | }), 172 | createDataPoint({ 173 | metricName: 'test.counter', 174 | metricType: 'counter', 175 | value: 10 176 | }), 177 | createDataPoint({ 178 | metricName: 'test.updown', 179 | metricType: 'updowncounter', 180 | value: -5 181 | }) 182 | ] 183 | }, 184 | { 185 | name: 'handles metrics with different attributes correctly', 186 | metrics: [ 187 | createDataPoint({ 188 | metricName: 'test.counter', 189 | attributes: { env: 'dev', team: 'alpha' }, 190 | value: 5 191 | }), 192 | createDataPoint({ 193 | metricName: 'test.counter', 194 | attributes: { env: 'prod', team: 'beta' }, 195 | value: 3 196 | }) 197 | ] 198 | }, 199 | { 200 | name: 'handles updowncounter negative values correctly', 201 | metrics: [ 202 | createDataPoint({ 203 | metricName: 'test.updown', 204 | metricType: 'updowncounter', 205 | value: 10 206 | }), 207 | createDataPoint({ 208 | metricName: 'test.updown', 209 | metricType: 'updowncounter', 210 | value: -15 211 | }) 212 | ] 213 | } 214 | ] 215 | 216 | for (const testCase of testCases) { 217 | it(`should ${testCase.name}`, async () => { 218 | submitter.submitMetrics(testCase.metrics) 219 | await metricReader.forceFlush() 220 | const expectedExports = testCase.expectExports ?? 1 221 | const exportedMetrics = await waitForNumberOfExports( 222 | exporter, 223 | expectedExports 224 | ) 225 | expect(exportedMetrics.length).toBe(expectedExports) 226 | 227 | const normalizedMetrics = normalizeMetricsForSnapshot(exportedMetrics) 228 | expect(normalizedMetrics).toMatchSnapshot() 229 | }) 230 | } 231 | 232 | it('should handle empty metrics array gracefully', async () => { 233 | submitter.submitMetrics([]) 234 | expect(submitter).toBeDefined() 235 | await metricReader.forceFlush() 236 | const exportedMetrics = exporter.getMetrics() 237 | expect(exportedMetrics).toHaveLength(0) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | import * as github from '@actions/github' 4 | import { ingestDir } from './junit-parser.js' 5 | import { generateMetrics, type TMetricsConfig } from './metrics-generator.js' 6 | import { MetricsSubmitter } from './metrics-submitter.js' 7 | import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' 8 | import { AggregationTemporalityPreference } from '@opentelemetry/exporter-metrics-otlp-http' 9 | import { resourceFromAttributes } from '@opentelemetry/resources' 10 | import { 11 | ATTR_SERVICE_NAME, 12 | ATTR_SERVICE_VERSION 13 | } from '@opentelemetry/semantic-conventions' 14 | import { 15 | ATTR_DEPLOYMENT_ENVIRONMENT_NAME, 16 | ATTR_SERVICE_NAMESPACE 17 | } from '@opentelemetry/semantic-conventions/incubating' 18 | 19 | const DEFAULT_EXPORT_INTERVAL_MS = 15000 20 | const DEFAULT_TIMEOUT_MS = 30000 21 | import { 22 | DiagConsoleLogger, 23 | DiagLogFunction, 24 | DiagLogLevel, 25 | DiagLogger, 26 | diag 27 | } from '@opentelemetry/api' 28 | 29 | import { 30 | MeterProvider, 31 | PeriodicExportingMetricReader 32 | } from '@opentelemetry/sdk-metrics' 33 | 34 | import { randomUUID } from 'crypto' 35 | class CapturingDiagLogger implements DiagLogger { 36 | private baseLogger: DiagConsoleLogger 37 | private capturedOutput: string = '' 38 | 39 | constructor() { 40 | this.baseLogger = new DiagConsoleLogger() 41 | } 42 | 43 | private capture(level: string, message: string, ...args: unknown[]) { 44 | const fullMessage = `[${level}] ${message} ${args.join(' ')}\n` 45 | this.capturedOutput += fullMessage 46 | } 47 | 48 | error: DiagLogFunction = (message: string, ...args: unknown[]) => { 49 | this.capture('ERROR', message, ...args) 50 | this.baseLogger.error(message, ...args) 51 | } 52 | 53 | warn: DiagLogFunction = (message: string, ...args: unknown[]) => { 54 | this.capture('WARN', message, ...args) 55 | this.baseLogger.warn(message, ...args) 56 | } 57 | 58 | info: DiagLogFunction = (message: string, ...args: unknown[]) => { 59 | this.capture('INFO', message, ...args) 60 | this.baseLogger.info(message, ...args) 61 | } 62 | 63 | debug: DiagLogFunction = (message: string, ...args: unknown[]) => { 64 | this.capture('DEBUG', message, ...args) 65 | this.baseLogger.debug(message, ...args) 66 | } 67 | 68 | verbose: DiagLogFunction = (message: string, ...args: unknown[]) => { 69 | this.capture('VERBOSE', message, ...args) 70 | this.baseLogger.verbose(message, ...args) 71 | } 72 | 73 | getCapturedOutput(): string { 74 | return this.capturedOutput 75 | } 76 | } 77 | 78 | export async function run(): Promise { 79 | try { 80 | const logger = new CapturingDiagLogger() 81 | diag.setLogger(logger, DiagLogLevel.ERROR) 82 | 83 | const junitXmlFolder = core.getInput('junit-xml-folder', { required: true }) 84 | const serviceName = core.getInput('service-name', { required: true }) 85 | const serviceNamespace = core.getInput('service-namespace', { 86 | required: true 87 | }) 88 | const deploymentEnvironment = 89 | core.getInput('deployment-environment') || 'staging' 90 | const otlpEndpoint = core.getInput('otlp-endpoint', { required: true }) 91 | 92 | const serviceVersion = 93 | core.getInput('service-version') || github.context.sha.substring(0, 8) 94 | const otlpHeaders = core.getInput('otlp-headers') || '' 95 | 96 | const headers = parseOtlpHeaders(otlpHeaders) 97 | 98 | const metricsNamespace = core.getInput('metrics-namespace') || 'cae' 99 | 100 | const metricsVersion = core.getInput('metrics-version') || 'v12' 101 | 102 | const config: TMetricsConfig = { 103 | serviceName, 104 | serviceNamespace, 105 | serviceVersion, 106 | environment: deploymentEnvironment, 107 | repository: `${github.context.repo.owner}/${github.context.repo.repo}`, 108 | branch: github.context.ref.replace('refs/heads/', ''), 109 | commitSha: github.context.sha, 110 | runId: github.context.runId.toString(), 111 | jobUUID: randomUUID() 112 | } 113 | 114 | core.info(`🔧 Configuring OpenTelemetry CI Visibility`) 115 | core.info( 116 | ` Service: ${serviceNamespace}/${serviceName} v${serviceVersion}` 117 | ) 118 | core.info(` Environment: ${deploymentEnvironment}`) 119 | core.info(` JUnit XML Folder: ${junitXmlFolder}`) 120 | core.info(` OTLP Endpoint: ${otlpEndpoint}`) 121 | 122 | const resource = resourceFromAttributes({ 123 | [ATTR_SERVICE_NAME]: serviceName, 124 | [ATTR_SERVICE_NAMESPACE]: serviceNamespace, 125 | [ATTR_SERVICE_VERSION]: serviceVersion, 126 | [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: deploymentEnvironment 127 | }) 128 | 129 | const exporter = new OTLPMetricExporter({ 130 | url: otlpEndpoint, 131 | headers, 132 | timeoutMillis: DEFAULT_TIMEOUT_MS, 133 | temporalityPreference: AggregationTemporalityPreference.CUMULATIVE 134 | }) 135 | 136 | const readers = [ 137 | new PeriodicExportingMetricReader({ 138 | exporter, 139 | exportIntervalMillis: DEFAULT_EXPORT_INTERVAL_MS 140 | }) 141 | ] 142 | 143 | const meterProvider = new MeterProvider({ 144 | resource, 145 | readers 146 | }) 147 | 148 | const metricsSubmitter = new MetricsSubmitter( 149 | config, 150 | meterProvider, 151 | metricsNamespace, 152 | metricsVersion 153 | ) 154 | 155 | core.info(`📊 Processing JUnit XML files from: ${junitXmlFolder}`) 156 | 157 | const ingestResult = ingestDir(junitXmlFolder) 158 | 159 | if (!ingestResult.success) { 160 | core.error(`Failed to ingest JUnit XML files: ${ingestResult.error}`) 161 | return 162 | } 163 | 164 | const report = ingestResult.data 165 | 166 | if (report.testsuites.length === 0) { 167 | core.warning(`No test suites found in ${junitXmlFolder}`) 168 | return 169 | } 170 | 171 | const metricDataPoints = generateMetrics(report, config) 172 | core.info( 173 | `Generated ${metricDataPoints.length} metrics from ${report.testsuites.length} test suites` 174 | ) 175 | metricsSubmitter.submitMetrics(metricDataPoints) 176 | 177 | core.info( 178 | `Summary: ${report.totals.tests} tests, ${report.totals.failed} failures, ${report.totals.error} errors, ${report.totals.skipped} skipped` 179 | ) 180 | 181 | await meterProvider.forceFlush() 182 | 183 | const diagOutput = logger.getCapturedOutput() 184 | 185 | if (diagOutput.includes('metrics export failed')) { 186 | core.error(`❌ CI visibility metrics submission failed: ${diagOutput}`) 187 | core.setFailed(`Action failed: ${diagOutput}`) 188 | } else { 189 | core.info(`✅ CI visibility metrics submitted successfully`) 190 | } 191 | } catch (error) { 192 | const errorMessage = error instanceof Error ? error.message : String(error) 193 | core.error(`❌ CI visibility metrics submission failed: ${errorMessage}`) 194 | core.setFailed(`Action failed: ${errorMessage}`) 195 | } 196 | } 197 | 198 | const parseOtlpHeaders = (headersInput: string): Record => { 199 | if (!headersInput.trim()) { 200 | return {} 201 | } 202 | 203 | const headers: Record = {} 204 | 205 | try { 206 | if (headersInput.trim().startsWith('{')) { 207 | return JSON.parse(headersInput) 208 | } else { 209 | const pairs = headersInput.split(',') 210 | for (const pair of pairs) { 211 | const [key, ...valueParts] = pair.split('=') 212 | if (key && valueParts.length > 0) { 213 | headers[key.trim()] = valueParts.join('=').trim() 214 | } 215 | } 216 | } 217 | } catch (parseError) { 218 | core.warning( 219 | `Failed to parse OTLP headers: ${parseError instanceof Error ? parseError.message : String(parseError)}` 220 | ) 221 | } 222 | 223 | return headers 224 | } 225 | -------------------------------------------------------------------------------- /src/__test-fixtures__/junit-complete.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 22 | 24 | 25 | 39 | 42 | 43 | 45 | 46 | 48 | 49 | 50 | 51 | 52 | 53 | Config line #1 54 | Config line #2 55 | Config line #3 56 | 57 | 58 | 59 | 61 | Data written to standard out. 62 | 63 | 65 | Data written to standard error. 66 | 67 | 77 | 79 | 81 | 83 | 84 | 85 | 87 | 89 | 90 | 91 | 92 | 93 | 95 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 106 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 117 | 118 | Data written to standard out. 119 | 120 | 121 | Data written to standard error. 122 | 123 | 124 | 125 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | This text describes the purpose of this test case and provides 136 | an overview of what the test does and how it works. 137 | 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /src/metrics-generator.test.ts: -------------------------------------------------------------------------------- 1 | import { generateMetrics, type TMetricsConfig } from './metrics-generator.js' 2 | import type { TJUnitReport, TSuite, TTest } from './junit-parser.js' 3 | import { setTimeout } from 'timers/promises' 4 | describe('generateMetrics', () => { 5 | const config: TMetricsConfig = { 6 | serviceName: 'test-service', 7 | serviceVersion: '1.0.0', 8 | environment: 'test', 9 | repository: 'test/repo', 10 | branch: 'main', 11 | commitSha: 'abc123', 12 | runId: 'build-456', 13 | jobUUID: 'job-456' 14 | } 15 | 16 | const createTest = (overrides: Partial = {}): TTest => ({ 17 | name: 'test1', 18 | classname: 'com.example.Test', 19 | time: 1.5, 20 | result: { status: 'passed' }, 21 | properties: undefined, 22 | systemOut: undefined, 23 | systemErr: undefined, 24 | ...overrides 25 | }) 26 | 27 | const createSuite = (overrides: Partial = {}): TSuite => ({ 28 | name: 'TestSuite', 29 | properties: undefined, 30 | tests: [createTest()], 31 | suites: undefined, 32 | systemOut: undefined, 33 | systemErr: undefined, 34 | totals: { 35 | tests: 1, 36 | passed: 1, 37 | failed: 0, 38 | error: 0, 39 | skipped: 0, 40 | time: 1.5, 41 | cumulativeTime: 1.5 42 | }, 43 | ...overrides 44 | }) 45 | 46 | const createReport = (suites: TSuite[]): TJUnitReport => ({ 47 | testsuites: suites, 48 | totals: suites.reduce( 49 | (acc, suite) => ({ 50 | tests: acc.tests + suite.totals.tests, 51 | passed: acc.passed + suite.totals.passed, 52 | failed: acc.failed + suite.totals.failed, 53 | error: acc.error + suite.totals.error, 54 | skipped: acc.skipped + suite.totals.skipped, 55 | time: acc.time + suite.totals.time, 56 | cumulativeTime: acc.cumulativeTime + suite.totals.cumulativeTime 57 | }), 58 | { 59 | tests: 0, 60 | passed: 0, 61 | failed: 0, 62 | error: 0, 63 | skipped: 0, 64 | time: 0, 65 | cumulativeTime: 0 66 | } 67 | ) 68 | }) 69 | 70 | it('generates correct metrics structure for simple passed test', async () => { 71 | const report = createReport([createSuite()]) 72 | const metrics = generateMetrics(report, config) 73 | await setTimeout(4000, () => {}) 74 | expect(metrics).toHaveLength(1) 75 | 76 | expect(metrics.map((m) => ({ name: m.metricName, type: m.metricType }))) 77 | .toMatchInlineSnapshot(` 78 | [ 79 | { 80 | "name": "test_duration_seconds", 81 | "type": "gauge", 82 | }, 83 | ] 84 | `) 85 | 86 | const firstMetric = metrics[0]! 87 | expect(firstMetric.attributes['service.name']).toBe('test-service') 88 | 89 | metrics.forEach((metric) => { 90 | expect(metric.attributes['service.name']).toBe('test-service') 91 | expect(metric.unit).toBeDefined() 92 | expect(metric.description).toBeDefined() 93 | }) 94 | 95 | const suiteMetrics = metrics.filter((m) => 96 | m.metricName.startsWith('test.suite') 97 | ) 98 | suiteMetrics.forEach((metric) => { 99 | expect(metric.attributes['test.framework']).toBe('junit') 100 | }) 101 | }) 102 | 103 | it('generates gauge metrics for test duration', () => { 104 | const report = createReport([createSuite()]) 105 | const metrics = generateMetrics(report, config) 106 | 107 | const testDuration = metrics.find( 108 | (m) => m.metricName === 'test_duration_seconds' 109 | ) 110 | 111 | expect(testDuration).toBeDefined() 112 | expect(testDuration?.metricType).toBe('gauge') 113 | expect(testDuration?.value).toBe(1.5) 114 | expect(testDuration?.unit).toBe('s') 115 | }) 116 | 117 | it('generates metrics for all test statuses', () => { 118 | const tests = [ 119 | createTest({ name: 'test1', result: { status: 'passed' } }), 120 | createTest({ 121 | name: 'test2', 122 | result: { 123 | status: 'failed', 124 | message: undefined, 125 | type: 'AssertionError', 126 | body: undefined 127 | } 128 | }), 129 | createTest({ 130 | name: 'test3', 131 | result: { 132 | status: 'error', 133 | message: undefined, 134 | type: 'RuntimeError', 135 | body: undefined 136 | } 137 | }), 138 | createTest({ 139 | name: 'test4', 140 | result: { status: 'skipped', message: undefined } 141 | }) 142 | ] 143 | 144 | const suite = createSuite({ 145 | tests, 146 | totals: { 147 | tests: 4, 148 | passed: 1, 149 | failed: 1, 150 | error: 1, 151 | skipped: 1, 152 | time: 6.0, 153 | cumulativeTime: 6.0 154 | } 155 | }) 156 | 157 | const report = createReport([suite]) 158 | const metrics = generateMetrics(report, config) 159 | 160 | expect(metrics).toHaveLength(4) 161 | expect(metrics.map((m) => m.attributes['test.status']).sort()).toEqual([ 162 | 'error', 163 | 'failed', 164 | 'passed', 165 | 'skipped' 166 | ]) 167 | 168 | expect(metrics.every((m) => m.metricName === 'test_duration_seconds')).toBe( 169 | true 170 | ) 171 | expect(metrics.every((m) => m.metricType === 'gauge')).toBe(true) 172 | }) 173 | 174 | it('handles nested suites recursively', () => { 175 | const nestedSuite = createSuite({ name: 'NestedSuite' }) 176 | const parentSuite = createSuite({ 177 | name: 'ParentSuite', 178 | tests: [], 179 | suites: [nestedSuite], 180 | totals: { 181 | tests: 1, 182 | passed: 1, 183 | failed: 0, 184 | error: 0, 185 | skipped: 0, 186 | time: 2.0, 187 | cumulativeTime: 2.0 188 | } 189 | }) 190 | 191 | const report = createReport([parentSuite]) 192 | const metrics = generateMetrics(report, config) 193 | 194 | expect(metrics).toHaveLength(1) 195 | 196 | expect(metrics[0]!.attributes['test.suite.name']).toBe('NestedSuite') 197 | }) 198 | 199 | it('uses OpenTelemetry semantic conventions for attribute names', () => { 200 | const report = createReport([createSuite()]) 201 | const metrics = generateMetrics(report, config) 202 | 203 | const baseAttributes = metrics[0]!.attributes 204 | expect(baseAttributes).toMatchInlineSnapshot( 205 | { 206 | 'service.name': expect.any(String), 207 | 'service.version': expect.any(String), 208 | 'deployment.environment': expect.any(String), 209 | 'vcs.repository.name': expect.any(String), 210 | 'vcs.repository.ref.name': expect.any(String), 211 | 'vcs.repository.ref.revision': expect.any(String), 212 | 'ci.run.id': expect.any(String) 213 | }, 214 | ` 215 | { 216 | "ci.job.id": "job-456", 217 | "ci.run.id": Any, 218 | "deployment.environment": Any, 219 | "service.name": Any, 220 | "service.version": Any, 221 | "test.class.name": "com.example.Test", 222 | "test.framework": "junit", 223 | "test.name": "test1", 224 | "test.status": "passed", 225 | "test.suite.name": "TestSuite", 226 | "vcs.repository.name": Any, 227 | "vcs.repository.ref.name": Any, 228 | "vcs.repository.ref.revision": Any, 229 | } 230 | ` 231 | ) 232 | }) 233 | 234 | it('handles minimal config with only required fields', () => { 235 | const report = createReport([createSuite()]) 236 | const metrics = generateMetrics(report, { 237 | serviceName: 'minimal', 238 | serviceVersion: undefined, 239 | environment: undefined, 240 | repository: undefined, 241 | branch: undefined, 242 | commitSha: undefined, 243 | runId: undefined, 244 | jobUUID: undefined 245 | }) 246 | 247 | expect(metrics.length).toBeGreaterThan(0) 248 | expect(metrics[0]!.attributes['service.name']).toBe('minimal') 249 | }) 250 | 251 | it('preserves duration values in seconds', () => { 252 | const testWithDuration = createTest({ time: 2.5 }) 253 | const suiteWithDuration = createSuite({ 254 | tests: [testWithDuration], 255 | totals: { 256 | tests: 1, 257 | passed: 1, 258 | failed: 0, 259 | error: 0, 260 | skipped: 0, 261 | time: 2.5, 262 | cumulativeTime: 2.5 263 | } 264 | }) 265 | 266 | const report = createReport([suiteWithDuration]) 267 | const metrics = generateMetrics(report, config) 268 | 269 | const testDuration = metrics.find( 270 | (m) => m.metricName === 'test_duration_seconds' 271 | ) 272 | 273 | expect(testDuration?.value).toBe(2.5) 274 | expect(testDuration?.unit).toBe('s') 275 | expect(testDuration?.metricType).toBe('gauge') 276 | }) 277 | }) 278 | -------------------------------------------------------------------------------- /src/__snapshots__/metrics-submitter.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`MetricsSubmitter should configures histogram with explicit buckets 1`] = ` 4 | [ 5 | { 6 | "resource": ResourceImpl { 7 | "_asyncAttributesPending": false, 8 | "_memoizedAttributes": undefined, 9 | "_rawAttributes": [ 10 | [ 11 | "service.name", 12 | "test-service", 13 | ], 14 | [ 15 | "service.version", 16 | "1.0.0", 17 | ], 18 | ], 19 | "_schemaUrl": undefined, 20 | }, 21 | "scopeMetrics": [ 22 | { 23 | "metrics": [ 24 | { 25 | "aggregationTemporality": 1, 26 | "dataPointType": 0, 27 | "dataPoints": [ 28 | { 29 | "attributes": { 30 | "test.name": "example", 31 | }, 32 | "value": { 33 | "buckets": { 34 | "boundaries": [ 35 | 0, 36 | 5, 37 | 10, 38 | 25, 39 | 50, 40 | 75, 41 | 100, 42 | 250, 43 | 500, 44 | 750, 45 | 1000, 46 | 2500, 47 | 5000, 48 | 7500, 49 | 10000, 50 | ], 51 | "counts": [ 52 | 0, 53 | 1, 54 | 0, 55 | 0, 56 | 0, 57 | 0, 58 | 0, 59 | 0, 60 | 0, 61 | 0, 62 | 0, 63 | 0, 64 | 0, 65 | 0, 66 | 0, 67 | 0, 68 | ], 69 | }, 70 | "count": 1, 71 | "max": 1, 72 | "min": 1, 73 | "sum": 1, 74 | }, 75 | }, 76 | ], 77 | "descriptor": { 78 | "advice": {}, 79 | "description": "Test metric", 80 | "name": "test-namespace.v1.test.duration", 81 | "type": "HISTOGRAM", 82 | "unit": "{test}", 83 | "valueType": 1, 84 | }, 85 | }, 86 | ], 87 | "scope": { 88 | "name": "test-service", 89 | "schemaUrl": undefined, 90 | "version": "1.0.0", 91 | }, 92 | }, 93 | ], 94 | }, 95 | ] 96 | `; 97 | 98 | exports[`MetricsSubmitter should creates instruments for each metric type 1`] = ` 99 | [ 100 | { 101 | "resource": ResourceImpl { 102 | "_asyncAttributesPending": false, 103 | "_memoizedAttributes": undefined, 104 | "_rawAttributes": [ 105 | [ 106 | "service.name", 107 | "test-service", 108 | ], 109 | [ 110 | "service.version", 111 | "1.0.0", 112 | ], 113 | ], 114 | "_schemaUrl": undefined, 115 | }, 116 | "scopeMetrics": [ 117 | { 118 | "metrics": [ 119 | { 120 | "aggregationTemporality": 1, 121 | "dataPointType": 0, 122 | "dataPoints": [ 123 | { 124 | "attributes": { 125 | "test.name": "example", 126 | }, 127 | "value": { 128 | "buckets": { 129 | "boundaries": [ 130 | 0, 131 | 5, 132 | 10, 133 | 25, 134 | 50, 135 | 75, 136 | 100, 137 | 250, 138 | 500, 139 | 750, 140 | 1000, 141 | 2500, 142 | 5000, 143 | 7500, 144 | 10000, 145 | ], 146 | "counts": [ 147 | 0, 148 | 1, 149 | 0, 150 | 0, 151 | 0, 152 | 0, 153 | 0, 154 | 0, 155 | 0, 156 | 0, 157 | 0, 158 | 0, 159 | 0, 160 | 0, 161 | 0, 162 | 0, 163 | ], 164 | }, 165 | "count": 1, 166 | "max": 1, 167 | "min": 1, 168 | "sum": 1, 169 | }, 170 | }, 171 | ], 172 | "descriptor": { 173 | "advice": {}, 174 | "description": "Test metric", 175 | "name": "test-namespace.v1.test.duration", 176 | "type": "HISTOGRAM", 177 | "unit": "s", 178 | "valueType": 1, 179 | }, 180 | }, 181 | { 182 | "aggregationTemporality": 1, 183 | "dataPointType": 3, 184 | "dataPoints": [ 185 | { 186 | "attributes": { 187 | "test.name": "example", 188 | }, 189 | "value": 1, 190 | }, 191 | ], 192 | "descriptor": { 193 | "advice": {}, 194 | "description": "Test metric", 195 | "name": "test-namespace.v1.test.status", 196 | "type": "COUNTER", 197 | "unit": "{test}", 198 | "valueType": 1, 199 | }, 200 | "isMonotonic": true, 201 | }, 202 | { 203 | "aggregationTemporality": 1, 204 | "dataPointType": 3, 205 | "dataPoints": [ 206 | { 207 | "attributes": { 208 | "test.name": "example", 209 | }, 210 | "value": 1, 211 | }, 212 | ], 213 | "descriptor": { 214 | "advice": {}, 215 | "description": "Test metric", 216 | "name": "test-namespace.v1.test.suite.total", 217 | "type": "UP_DOWN_COUNTER", 218 | "unit": "{test}", 219 | "valueType": 1, 220 | }, 221 | "isMonotonic": false, 222 | }, 223 | ], 224 | "scope": { 225 | "name": "test-service", 226 | "schemaUrl": undefined, 227 | "version": "1.0.0", 228 | }, 229 | }, 230 | ], 231 | }, 232 | ] 233 | `; 234 | 235 | exports[`MetricsSubmitter should handles metrics with different attributes correctly 1`] = ` 236 | [ 237 | { 238 | "resource": ResourceImpl { 239 | "_asyncAttributesPending": false, 240 | "_memoizedAttributes": undefined, 241 | "_rawAttributes": [ 242 | [ 243 | "service.name", 244 | "test-service", 245 | ], 246 | [ 247 | "service.version", 248 | "1.0.0", 249 | ], 250 | ], 251 | "_schemaUrl": undefined, 252 | }, 253 | "scopeMetrics": [ 254 | { 255 | "metrics": [ 256 | { 257 | "aggregationTemporality": 1, 258 | "dataPointType": 3, 259 | "dataPoints": [ 260 | { 261 | "attributes": { 262 | "env": "dev", 263 | "team": "alpha", 264 | }, 265 | "value": 5, 266 | }, 267 | { 268 | "attributes": { 269 | "env": "prod", 270 | "team": "beta", 271 | }, 272 | "value": 3, 273 | }, 274 | ], 275 | "descriptor": { 276 | "advice": {}, 277 | "description": "Test metric", 278 | "name": "test-namespace.v1.test.counter", 279 | "type": "COUNTER", 280 | "unit": "{test}", 281 | "valueType": 1, 282 | }, 283 | "isMonotonic": true, 284 | }, 285 | ], 286 | "scope": { 287 | "name": "test-service", 288 | "schemaUrl": undefined, 289 | "version": "1.0.0", 290 | }, 291 | }, 292 | ], 293 | }, 294 | ] 295 | `; 296 | 297 | exports[`MetricsSubmitter should handles multiple metric types in one submission 1`] = ` 298 | [ 299 | { 300 | "resource": ResourceImpl { 301 | "_asyncAttributesPending": false, 302 | "_memoizedAttributes": undefined, 303 | "_rawAttributes": [ 304 | [ 305 | "service.name", 306 | "test-service", 307 | ], 308 | [ 309 | "service.version", 310 | "1.0.0", 311 | ], 312 | ], 313 | "_schemaUrl": undefined, 314 | }, 315 | "scopeMetrics": [ 316 | { 317 | "metrics": [ 318 | { 319 | "aggregationTemporality": 1, 320 | "dataPointType": 0, 321 | "dataPoints": [ 322 | { 323 | "attributes": { 324 | "test.name": "example", 325 | }, 326 | "value": { 327 | "buckets": { 328 | "boundaries": [ 329 | 0, 330 | 5, 331 | 10, 332 | 25, 333 | 50, 334 | 75, 335 | 100, 336 | 250, 337 | 500, 338 | 750, 339 | 1000, 340 | 2500, 341 | 5000, 342 | 7500, 343 | 10000, 344 | ], 345 | "counts": [ 346 | 0, 347 | 0, 348 | 0, 349 | 0, 350 | 1, 351 | 0, 352 | 0, 353 | 0, 354 | 0, 355 | 0, 356 | 0, 357 | 0, 358 | 0, 359 | 0, 360 | 0, 361 | 0, 362 | ], 363 | }, 364 | "count": 1, 365 | "max": 42, 366 | "min": 42, 367 | "sum": 42, 368 | }, 369 | }, 370 | ], 371 | "descriptor": { 372 | "advice": {}, 373 | "description": "Test metric", 374 | "name": "test-namespace.v1.test.histogram", 375 | "type": "HISTOGRAM", 376 | "unit": "{test}", 377 | "valueType": 1, 378 | }, 379 | }, 380 | { 381 | "aggregationTemporality": 1, 382 | "dataPointType": 3, 383 | "dataPoints": [ 384 | { 385 | "attributes": { 386 | "test.name": "example", 387 | }, 388 | "value": 10, 389 | }, 390 | ], 391 | "descriptor": { 392 | "advice": {}, 393 | "description": "Test metric", 394 | "name": "test-namespace.v1.test.counter", 395 | "type": "COUNTER", 396 | "unit": "{test}", 397 | "valueType": 1, 398 | }, 399 | "isMonotonic": true, 400 | }, 401 | { 402 | "aggregationTemporality": 1, 403 | "dataPointType": 3, 404 | "dataPoints": [ 405 | { 406 | "attributes": { 407 | "test.name": "example", 408 | }, 409 | "value": -5, 410 | }, 411 | ], 412 | "descriptor": { 413 | "advice": {}, 414 | "description": "Test metric", 415 | "name": "test-namespace.v1.test.updown", 416 | "type": "UP_DOWN_COUNTER", 417 | "unit": "{test}", 418 | "valueType": 1, 419 | }, 420 | "isMonotonic": false, 421 | }, 422 | ], 423 | "scope": { 424 | "name": "test-service", 425 | "schemaUrl": undefined, 426 | "version": "1.0.0", 427 | }, 428 | }, 429 | ], 430 | }, 431 | ] 432 | `; 433 | 434 | exports[`MetricsSubmitter should handles updowncounter negative values correctly 1`] = ` 435 | [ 436 | { 437 | "resource": ResourceImpl { 438 | "_asyncAttributesPending": false, 439 | "_memoizedAttributes": undefined, 440 | "_rawAttributes": [ 441 | [ 442 | "service.name", 443 | "test-service", 444 | ], 445 | [ 446 | "service.version", 447 | "1.0.0", 448 | ], 449 | ], 450 | "_schemaUrl": undefined, 451 | }, 452 | "scopeMetrics": [ 453 | { 454 | "metrics": [ 455 | { 456 | "aggregationTemporality": 1, 457 | "dataPointType": 3, 458 | "dataPoints": [ 459 | { 460 | "attributes": { 461 | "test.name": "example", 462 | }, 463 | "value": -5, 464 | }, 465 | ], 466 | "descriptor": { 467 | "advice": {}, 468 | "description": "Test metric", 469 | "name": "test-namespace.v1.test.updown", 470 | "type": "UP_DOWN_COUNTER", 471 | "unit": "{test}", 472 | "valueType": 1, 473 | }, 474 | "isMonotonic": false, 475 | }, 476 | ], 477 | "scope": { 478 | "name": "test-service", 479 | "schemaUrl": undefined, 480 | "version": "1.0.0", 481 | }, 482 | }, 483 | ], 484 | }, 485 | ] 486 | `; 487 | 488 | exports[`MetricsSubmitter should reuses existing instruments for same metric name 1`] = ` 489 | [ 490 | { 491 | "resource": ResourceImpl { 492 | "_asyncAttributesPending": false, 493 | "_memoizedAttributes": undefined, 494 | "_rawAttributes": [ 495 | [ 496 | "service.name", 497 | "test-service", 498 | ], 499 | [ 500 | "service.version", 501 | "1.0.0", 502 | ], 503 | ], 504 | "_schemaUrl": undefined, 505 | }, 506 | "scopeMetrics": [ 507 | { 508 | "metrics": [ 509 | { 510 | "aggregationTemporality": 1, 511 | "dataPointType": 3, 512 | "dataPoints": [ 513 | { 514 | "attributes": { 515 | "test.name": "example", 516 | }, 517 | "value": 3, 518 | }, 519 | ], 520 | "descriptor": { 521 | "advice": {}, 522 | "description": "Test metric", 523 | "name": "test-namespace.v1.test.counter", 524 | "type": "COUNTER", 525 | "unit": "{test}", 526 | "valueType": 1, 527 | }, 528 | "isMonotonic": true, 529 | }, 530 | ], 531 | "scope": { 532 | "name": "test-service", 533 | "schemaUrl": undefined, 534 | "version": "1.0.0", 535 | }, 536 | }, 537 | ], 538 | }, 539 | ] 540 | `; 541 | -------------------------------------------------------------------------------- /src/junit-parser.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from 'fast-xml-parser' 2 | import { readFileSync, readdirSync, statSync } from 'fs' 3 | import { join, extname } from 'path' 4 | 5 | const MAX_XML_SIZE = 10 * 1024 * 1024 6 | const MAX_PROPERTY_NAME_LENGTH = 100 7 | const MAX_STRING_LENGTH = 50000 8 | const MAX_NESTING_DEPTH = 20 9 | const MAX_PROPERTIES_COUNT = 1000 10 | 11 | interface TTestResultPassed { 12 | readonly status: 'passed' 13 | } 14 | 15 | interface TTestResultSkipped { 16 | readonly status: 'skipped' 17 | /** Optional message describing why the test was skipped */ 18 | readonly message: string | undefined 19 | } 20 | 21 | interface TTestResultFailed { 22 | readonly status: 'failed' 23 | /** Failure message */ 24 | readonly message: string | undefined 25 | /** Type descriptor (typically assertion type) */ 26 | readonly type: string | undefined 27 | /** Extended failure description or stack trace */ 28 | readonly body: string | undefined 29 | } 30 | 31 | interface TTestResultError { 32 | readonly status: 'error' 33 | /** Error message */ 34 | readonly message: string | undefined 35 | /** Type descriptor (typically exception class) */ 36 | readonly type: string | undefined 37 | /** Extended error description or stack trace */ 38 | readonly body: string | undefined 39 | } 40 | 41 | export type TTestResult = 42 | | TTestResultPassed 43 | | TTestResultSkipped 44 | | TTestResultFailed 45 | | TTestResultError 46 | 47 | /** 48 | * Totals contains aggregated results across a set of test runs. 49 | * The following relation should hold true: Tests === (Passed + Skipped + Failed + Error) 50 | */ 51 | export interface TTotals { 52 | /** Total number of tests run */ 53 | readonly tests: number 54 | /** Total number of tests that passed successfully */ 55 | readonly passed: number 56 | /** Total number of tests that were skipped */ 57 | readonly skipped: number 58 | /** Total number of tests that resulted in a failure */ 59 | readonly failed: number 60 | /** Total number of tests that resulted in an error */ 61 | readonly error: number 62 | /** Total time taken to run all tests in seconds (from XML time attribute) */ 63 | readonly time: number 64 | /** Calculated cumulative time of all child elements in seconds */ 65 | readonly cumulativeTime: number 66 | } 67 | 68 | /** 69 | * Test represents the results of a single test run. 70 | */ 71 | export interface TTest { 72 | /** Descriptor given to the test */ 73 | readonly name: string 74 | /** Additional descriptor for the hierarchy of the test */ 75 | readonly classname: string 76 | /** Total time taken to run the test in seconds (from XML time attribute) */ 77 | readonly time: number 78 | /** Result of the test */ 79 | readonly result: TTestResult 80 | /** Additional properties from XML node attributes */ 81 | readonly properties: Readonly> | undefined 82 | /** Textual output for the test case (stdout) */ 83 | readonly systemOut: string | undefined 84 | /** Textual error output for the test case (stderr) */ 85 | readonly systemErr: string | undefined 86 | } 87 | 88 | /** 89 | * Suite represents a logical grouping (suite) of tests. 90 | */ 91 | export interface TSuite { 92 | /** Descriptor given to the suite */ 93 | readonly name: string 94 | /** Mapping of key-value pairs that were available when the tests were run */ 95 | readonly properties: Readonly> | undefined 96 | /** Ordered collection of tests with associated results */ 97 | readonly tests: readonly TTest[] 98 | /** Ordered collection of suites with associated tests */ 99 | readonly suites: readonly TSuite[] | undefined 100 | /** Textual test output for the suite (stdout) */ 101 | readonly systemOut: string | undefined 102 | /** Textual test error output for the suite (stderr) */ 103 | readonly systemErr: string | undefined 104 | /** Aggregated results of all tests */ 105 | readonly totals: TTotals 106 | } 107 | 108 | /** 109 | * JUnitReport represents the complete test report. 110 | */ 111 | export interface TJUnitReport { 112 | /** Collection of test suites */ 113 | readonly testsuites: readonly TSuite[] 114 | /** Overall totals across all suites */ 115 | readonly totals: TTotals 116 | } 117 | 118 | /** 119 | * Result types for operations that can succeed or fail 120 | */ 121 | export type TOk = { 122 | success: true 123 | data: TData 124 | } 125 | 126 | export type TErr = { 127 | success: false 128 | error: TError 129 | } 130 | 131 | export type TResult = TOk | TErr 132 | 133 | const validateInput = (xmlContent: string): TResult => { 134 | if (typeof xmlContent !== 'string') { 135 | return { 136 | success: false, 137 | error: 'XML content must be a string' 138 | } 139 | } 140 | 141 | if (xmlContent.length > MAX_XML_SIZE) { 142 | return { 143 | success: false, 144 | error: `XML content exceeds maximum size of ${MAX_XML_SIZE} bytes` 145 | } 146 | } 147 | 148 | if (/ { 159 | if (value == null) return '' 160 | const str = String(value).trim() 161 | return str.length > MAX_STRING_LENGTH 162 | ? str.substring(0, MAX_STRING_LENGTH) + '...[truncated]' 163 | : str 164 | } 165 | 166 | const parsePositiveFloat = (value: unknown): number => { 167 | const num = parseFloat(String(value || '0')) 168 | const result = Number.isNaN(num) || num < 0 ? 0 : num 169 | return roundTime(result) 170 | } 171 | 172 | const roundTime = (time: number): number => { 173 | return Number(time.toFixed(6)) 174 | } 175 | 176 | const validatePropertyName = (name: string): boolean => { 177 | if (!name || typeof name !== 'string') return false 178 | if (name.length > MAX_PROPERTY_NAME_LENGTH) return false 179 | if (name === '__proto__' || name === 'constructor' || name === 'prototype') 180 | return false 181 | return true 182 | } 183 | 184 | const validateNestingDepth = (depth: number): TResult => { 185 | if (depth > MAX_NESTING_DEPTH) { 186 | return { 187 | success: false, 188 | error: `Maximum nesting depth of ${MAX_NESTING_DEPTH} exceeded` 189 | } 190 | } 191 | return { success: true, data: undefined } 192 | } 193 | 194 | const parseProperties = ( 195 | /* eslint-disable @typescript-eslint/no-explicit-any */ 196 | propertiesElement: any 197 | ): TResult | undefined> => { 198 | if (!propertiesElement || !propertiesElement.property) { 199 | return { success: true, data: undefined } 200 | } 201 | 202 | const properties: Record = Object.create(null) 203 | const props = Array.isArray(propertiesElement.property) 204 | ? propertiesElement.property 205 | : [propertiesElement.property] 206 | 207 | let propertyCount = 0 208 | for (const prop of props) { 209 | if (propertyCount >= MAX_PROPERTIES_COUNT) { 210 | return { 211 | success: false, 212 | error: `Maximum properties count of ${MAX_PROPERTIES_COUNT} exceeded` 213 | } 214 | } 215 | 216 | const rawName = prop['@_name'] 217 | if (!validatePropertyName(rawName)) { 218 | continue 219 | } 220 | 221 | const name = sanitizeString(rawName) 222 | propertyCount++ 223 | 224 | let value: string 225 | if (prop['@_value'] !== undefined) { 226 | value = sanitizeString(prop['@_value']) 227 | } else if (prop['#text'] !== undefined) { 228 | value = sanitizeString(prop['#text']) 229 | } else if (typeof prop === 'string') { 230 | value = sanitizeString(prop) 231 | } else { 232 | value = '' 233 | } 234 | 235 | properties[name] = value 236 | } 237 | 238 | const result = Object.keys(properties).length > 0 ? properties : undefined 239 | return { success: true, data: result } 240 | } 241 | 242 | const parseTest = (testcase: any): TResult => { 243 | let result: TTestResult 244 | 245 | if (testcase.failure) { 246 | result = { 247 | status: 'failed', 248 | message: sanitizeString(testcase.failure['@_message']), 249 | type: sanitizeString(testcase.failure['@_type']), 250 | body: sanitizeString(testcase.failure['#text']) 251 | } 252 | } else if (testcase.error) { 253 | result = { 254 | status: 'error', 255 | message: sanitizeString(testcase.error['@_message']), 256 | type: sanitizeString(testcase.error['@_type']), 257 | body: sanitizeString(testcase.error['#text']) 258 | } 259 | } else if (testcase.skipped !== undefined) { 260 | result = { 261 | status: 'skipped', 262 | message: testcase.skipped['@_message'] 263 | ? sanitizeString(testcase.skipped['@_message']) 264 | : undefined 265 | } 266 | } else { 267 | result = { status: 'passed' } 268 | } 269 | 270 | const propertiesResult = parseProperties(testcase.properties) 271 | if (!propertiesResult.success) { 272 | return propertiesResult 273 | } 274 | 275 | const testData: TTest = { 276 | name: sanitizeString(testcase['@_name']), 277 | classname: sanitizeString(testcase['@_classname']), 278 | time: parsePositiveFloat(testcase['@_time']), 279 | result, 280 | properties: propertiesResult.data, 281 | systemOut: testcase['system-out'] 282 | ? sanitizeString(testcase['system-out']) 283 | : undefined, 284 | systemErr: testcase['system-err'] 285 | ? sanitizeString(testcase['system-err']) 286 | : undefined 287 | } 288 | 289 | return { success: true, data: testData } 290 | } 291 | 292 | const parseSuite = (suite: any, depth: number = 0): TResult => { 293 | const depthValidation = validateNestingDepth(depth) 294 | if (!depthValidation.success) { 295 | return { success: false, error: depthValidation.error } 296 | } 297 | 298 | const testcases = suite.testcase 299 | ? Array.isArray(suite.testcase) 300 | ? suite.testcase 301 | : [suite.testcase] 302 | : [] 303 | 304 | const parsedTests: TTest[] = [] 305 | for (const testcase of testcases) { 306 | const testResult = parseTest(testcase) 307 | if (!testResult.success) { 308 | return testResult 309 | } 310 | parsedTests.push(testResult.data) 311 | } 312 | 313 | const nestedSuites = suite.testsuite 314 | ? Array.isArray(suite.testsuite) 315 | ? suite.testsuite 316 | : [suite.testsuite] 317 | : [] 318 | 319 | const parsedNestedSuites: TSuite[] = [] 320 | for (const nestedSuite of nestedSuites) { 321 | const suiteResult = parseSuite(nestedSuite, depth + 1) 322 | if (!suiteResult.success) { 323 | return suiteResult 324 | } 325 | parsedNestedSuites.push(suiteResult.data) 326 | } 327 | 328 | const propertiesResult = parseProperties(suite.properties) 329 | if (!propertiesResult.success) { 330 | return propertiesResult 331 | } 332 | 333 | const originalTime = parsePositiveFloat(suite['@_time']) 334 | const totals = calculateTotals( 335 | parsedTests, 336 | parsedNestedSuites.length > 0 ? parsedNestedSuites : undefined, 337 | originalTime 338 | ) 339 | 340 | const suiteData: TSuite = { 341 | name: sanitizeString(suite['@_name']), 342 | properties: propertiesResult.data, 343 | tests: parsedTests, 344 | suites: parsedNestedSuites.length > 0 ? parsedNestedSuites : undefined, 345 | systemOut: suite['system-out'] 346 | ? sanitizeString(suite['system-out']) 347 | : undefined, 348 | systemErr: suite['system-err'] 349 | ? sanitizeString(suite['system-err']) 350 | : undefined, 351 | totals: totals 352 | } 353 | 354 | return { success: true, data: suiteData } 355 | } 356 | 357 | const calculateTotals = ( 358 | tests: readonly TTest[], 359 | nestedSuites?: readonly TSuite[], 360 | originalTime?: number 361 | ): TTotals => { 362 | let totalTests = tests.length 363 | let totalPassed = 0 364 | let totalSkipped = 0 365 | let totalFailed = 0 366 | let totalError = 0 367 | let cumulativeTime = 0 368 | 369 | for (const test of tests) { 370 | cumulativeTime += test.time 371 | switch (test.result.status) { 372 | case 'passed': 373 | totalPassed++ 374 | break 375 | case 'skipped': 376 | totalSkipped++ 377 | break 378 | case 'failed': 379 | totalFailed++ 380 | break 381 | case 'error': 382 | totalError++ 383 | break 384 | } 385 | } 386 | 387 | if (nestedSuites) { 388 | for (const suite of nestedSuites) { 389 | totalTests += suite.totals.tests 390 | totalPassed += suite.totals.passed 391 | totalSkipped += suite.totals.skipped 392 | totalFailed += suite.totals.failed 393 | totalError += suite.totals.error 394 | cumulativeTime += suite.totals.cumulativeTime 395 | } 396 | } 397 | 398 | return { 399 | tests: totalTests, 400 | passed: totalPassed, 401 | skipped: totalSkipped, 402 | failed: totalFailed, 403 | error: totalError, 404 | time: originalTime || roundTime(cumulativeTime), 405 | cumulativeTime: roundTime(cumulativeTime) 406 | } 407 | } 408 | 409 | /** 410 | * Parse JUnit XML content and return a minimal common subset report. 411 | * Only parses attributes that are universally supported across JUnit implementations. 412 | */ 413 | export const parseJUnitXML = (xmlContent: string): TResult => { 414 | const validation = validateInput(xmlContent) 415 | if (!validation.success) { 416 | return { success: false, error: validation.error } 417 | } 418 | 419 | const parser = new XMLParser({ 420 | ignoreAttributes: false, 421 | attributeNamePrefix: '@_', 422 | textNodeName: '#text', 423 | parseAttributeValue: false, 424 | trimValues: true, 425 | processEntities: false, 426 | allowBooleanAttributes: false, 427 | ignoreDeclaration: true, 428 | ignorePiTags: true 429 | }) 430 | 431 | const result = parser.parse(validation.data) 432 | 433 | const testsuites = result.testsuites 434 | ? Array.isArray(result.testsuites.testsuite) 435 | ? result.testsuites.testsuite 436 | : [result.testsuites.testsuite] 437 | : [result.testsuite] 438 | 439 | const parsedSuites: TSuite[] = [] 440 | for (const suite of testsuites) { 441 | const suiteResult = parseSuite(suite) 442 | if (!suiteResult.success) { 443 | return suiteResult 444 | } 445 | parsedSuites.push(suiteResult.data) 446 | } 447 | 448 | const calculatedTotals = parsedSuites.reduce( 449 | (acc, suite) => ({ 450 | tests: acc.tests + suite.totals.tests, 451 | passed: acc.passed + suite.totals.passed, 452 | skipped: acc.skipped + suite.totals.skipped, 453 | failed: acc.failed + suite.totals.failed, 454 | error: acc.error + suite.totals.error, 455 | time: roundTime(acc.time + suite.totals.time), 456 | cumulativeTime: roundTime( 457 | acc.cumulativeTime + suite.totals.cumulativeTime 458 | ) 459 | }), 460 | { 461 | tests: 0, 462 | passed: 0, 463 | skipped: 0, 464 | failed: 0, 465 | error: 0, 466 | time: 0, 467 | cumulativeTime: 0 468 | } 469 | ) 470 | 471 | if (!result.testsuites) { 472 | return { 473 | success: false, 474 | error: 'XML must have a testsuites wrapper element with time attribute' 475 | } 476 | } 477 | 478 | if (!result.testsuites['@_time']) { 479 | return { 480 | success: false, 481 | error: 'testsuites element is missing required time attribute' 482 | } 483 | } 484 | 485 | const originalTestsuitesTime = parsePositiveFloat(result.testsuites['@_time']) 486 | 487 | const reportTotals = { 488 | ...calculatedTotals, 489 | time: originalTestsuitesTime, 490 | cumulativeTime: roundTime(calculatedTotals.time) 491 | } 492 | 493 | const reportData: TJUnitReport = { 494 | testsuites: parsedSuites, 495 | totals: reportTotals 496 | } 497 | 498 | return { success: true, data: reportData } 499 | } 500 | 501 | export const aggregate = (suite: TSuite): TSuite => { 502 | let totalTests = suite.tests.length 503 | let totalPassed = 0 504 | let totalSkipped = 0 505 | let totalFailed = 0 506 | let totalError = 0 507 | let totalCumulativeTime = 0 508 | 509 | for (const test of suite.tests) { 510 | totalCumulativeTime += test.time 511 | switch (test.result.status) { 512 | case 'passed': 513 | totalPassed++ 514 | break 515 | case 'skipped': 516 | totalSkipped++ 517 | break 518 | case 'failed': 519 | totalFailed++ 520 | break 521 | case 'error': 522 | totalError++ 523 | break 524 | } 525 | } 526 | 527 | const updatedNestedSuites: readonly TSuite[] | undefined = suite.suites?.map( 528 | (nestedSuite) => { 529 | const updatedNestedSuite = aggregate(nestedSuite) 530 | const { tests, cumulativeTime, passed, skipped, failed, error } = 531 | updatedNestedSuite.totals 532 | 533 | totalTests += tests 534 | totalCumulativeTime += cumulativeTime 535 | totalPassed += passed 536 | totalSkipped += skipped 537 | totalFailed += failed 538 | totalError += error 539 | 540 | return updatedNestedSuite 541 | } 542 | ) 543 | 544 | const updatedTotals = { 545 | tests: totalTests, 546 | passed: totalPassed, 547 | skipped: totalSkipped, 548 | failed: totalFailed, 549 | error: totalError, 550 | time: suite.totals.time, 551 | cumulativeTime: totalCumulativeTime 552 | } 553 | 554 | return { 555 | ...suite, 556 | suites: updatedNestedSuites, 557 | totals: updatedTotals 558 | } 559 | } 560 | 561 | const parseMultiRootXML = (xmlContent: string): TResult => { 562 | const firstResult = parseJUnitXML(xmlContent) 563 | if (firstResult.success) { 564 | return firstResult 565 | } 566 | 567 | const wrappedXml = `${xmlContent}` 568 | const secondResult = parseJUnitXML(wrappedXml) 569 | if (secondResult.success) { 570 | return secondResult 571 | } 572 | 573 | return firstResult 574 | } 575 | 576 | export const ingestFile = (filePath: string): TResult => { 577 | try { 578 | const xmlContent = readFileSync(filePath, 'utf-8') 579 | const result = parseMultiRootXML(xmlContent) 580 | if (!result.success) { 581 | return { 582 | success: false, 583 | error: `Failed to ingest file ${filePath}: ${result.error}` 584 | } 585 | } 586 | return result 587 | } catch (error) { 588 | const errorMessage = error instanceof Error ? error.message : String(error) 589 | return { 590 | success: false, 591 | error: `Failed to ingest file ${filePath}: ${errorMessage}` 592 | } 593 | } 594 | } 595 | 596 | export const ingestFiles = (filePaths: string[]): TResult => { 597 | const allSuites: TSuite[] = [] 598 | 599 | for (const filePath of filePaths) { 600 | const result = ingestFile(filePath) 601 | if (!result.success) { 602 | return result 603 | } 604 | allSuites.push(...result.data.testsuites) 605 | } 606 | 607 | const reportTotals = allSuites.reduce( 608 | (acc, suite) => ({ 609 | tests: acc.tests + suite.totals.tests, 610 | passed: acc.passed + suite.totals.passed, 611 | skipped: acc.skipped + suite.totals.skipped, 612 | failed: acc.failed + suite.totals.failed, 613 | error: acc.error + suite.totals.error, 614 | time: roundTime(acc.time + suite.totals.time), 615 | cumulativeTime: roundTime( 616 | acc.cumulativeTime + suite.totals.cumulativeTime 617 | ) 618 | }), 619 | { 620 | tests: 0, 621 | passed: 0, 622 | skipped: 0, 623 | failed: 0, 624 | error: 0, 625 | time: 0, 626 | cumulativeTime: 0 627 | } 628 | ) 629 | 630 | const reportData: TJUnitReport = { 631 | testsuites: allSuites, 632 | totals: reportTotals 633 | } 634 | 635 | return { success: true, data: reportData } 636 | } 637 | 638 | export const ingestDir = (dirPath: string): TResult => { 639 | try { 640 | const stat = statSync(dirPath) 641 | if (!stat.isDirectory()) { 642 | return { 643 | success: false, 644 | error: `Path ${dirPath} is not a directory` 645 | } 646 | } 647 | 648 | const files = readdirSync(dirPath) 649 | const xmlFiles = files 650 | .filter((file) => extname(file).toLowerCase() === '.xml') 651 | .map((file) => join(dirPath, file)) 652 | 653 | if (xmlFiles.length === 0) { 654 | const emptyReport: TJUnitReport = { 655 | testsuites: [], 656 | totals: { 657 | tests: 0, 658 | passed: 0, 659 | skipped: 0, 660 | failed: 0, 661 | error: 0, 662 | time: 0, 663 | cumulativeTime: 0 664 | } 665 | } 666 | return { success: true, data: emptyReport } 667 | } 668 | 669 | return ingestFiles(xmlFiles) 670 | } catch (error) { 671 | const errorMessage = error instanceof Error ? error.message : String(error) 672 | return { 673 | success: false, 674 | error: `Failed to ingest directory ${dirPath}: ${errorMessage}` 675 | } 676 | } 677 | } 678 | -------------------------------------------------------------------------------- /src/junit-parser.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type TResult, 3 | parseJUnitXML, 4 | ingestFile, 5 | ingestDir 6 | } from './junit-parser.js' 7 | import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs' 8 | import { join } from 'path' 9 | const readFixture = (filename: string): string => { 10 | return readFileSync(`src/__test-fixtures__/${filename}`, { 11 | encoding: 'utf-8' 12 | }) 13 | } 14 | 15 | const expectSuccess = (result: TResult): T => { 16 | if (!result.success) { 17 | throw new Error(`Expected success but got error: ${result.error}`) 18 | } 19 | return result.data 20 | } 21 | 22 | describe('JUnit XML Parser', () => { 23 | test('should parse complete junit xml', () => { 24 | const xml = readFixture('junit-complete.xml') 25 | const result = expectSuccess(parseJUnitXML(xml)) 26 | expect(result).toMatchInlineSnapshot(` 27 | { 28 | "testsuites": [ 29 | { 30 | "name": "Tests.Registration", 31 | "properties": { 32 | "browser": "Google Chrome", 33 | "ci": "https://github.com/actions/runs/1234", 34 | "commit": "ef7bebf", 35 | "config": "Config line #1 36 | Config line #2 37 | Config line #3", 38 | "version": "1.774", 39 | }, 40 | "suites": undefined, 41 | "systemErr": "Data written to standard error.", 42 | "systemOut": "Data written to standard out.", 43 | "tests": [ 44 | { 45 | "classname": "Tests.Registration", 46 | "name": "testCase1", 47 | "properties": undefined, 48 | "result": { 49 | "status": "passed", 50 | }, 51 | "systemErr": undefined, 52 | "systemOut": undefined, 53 | "time": 2.436, 54 | }, 55 | { 56 | "classname": "Tests.Registration", 57 | "name": "testCase2", 58 | "properties": undefined, 59 | "result": { 60 | "status": "passed", 61 | }, 62 | "systemErr": undefined, 63 | "systemOut": undefined, 64 | "time": 1.534, 65 | }, 66 | { 67 | "classname": "Tests.Registration", 68 | "name": "testCase3", 69 | "properties": undefined, 70 | "result": { 71 | "status": "passed", 72 | }, 73 | "systemErr": undefined, 74 | "systemOut": undefined, 75 | "time": 0.822, 76 | }, 77 | { 78 | "classname": "Tests.Registration", 79 | "name": "testCase4", 80 | "properties": undefined, 81 | "result": { 82 | "message": "Test was skipped.", 83 | "status": "skipped", 84 | }, 85 | "systemErr": undefined, 86 | "systemOut": undefined, 87 | "time": 0, 88 | }, 89 | { 90 | "classname": "Tests.Registration", 91 | "name": "testCase5", 92 | "properties": undefined, 93 | "result": { 94 | "body": "", 95 | "message": "Expected value did not match.", 96 | "status": "failed", 97 | "type": "AssertionError", 98 | }, 99 | "systemErr": undefined, 100 | "systemOut": undefined, 101 | "time": 2.902412, 102 | }, 103 | { 104 | "classname": "Tests.Registration", 105 | "name": "testCase6", 106 | "properties": undefined, 107 | "result": { 108 | "body": "", 109 | "message": "Division by zero.", 110 | "status": "error", 111 | "type": "ArithmeticError", 112 | }, 113 | "systemErr": undefined, 114 | "systemOut": undefined, 115 | "time": 3.819, 116 | }, 117 | { 118 | "classname": "Tests.Registration", 119 | "name": "testCase7", 120 | "properties": undefined, 121 | "result": { 122 | "status": "passed", 123 | }, 124 | "systemErr": "Data written to standard error.", 125 | "systemOut": "Data written to standard out.", 126 | "time": 2.944, 127 | }, 128 | { 129 | "classname": "Tests.Registration", 130 | "name": "testCase8", 131 | "properties": { 132 | "attachment": "screenshots/users.png", 133 | "author": "Adrian", 134 | "description": "This text describes the purpose of this test case and provides 135 | an overview of what the test does and how it works.", 136 | "language": "english", 137 | "priority": "high", 138 | }, 139 | "result": { 140 | "status": "passed", 141 | }, 142 | "systemErr": undefined, 143 | "systemOut": undefined, 144 | "time": 1.625275, 145 | }, 146 | ], 147 | "totals": { 148 | "cumulativeTime": 16.082687, 149 | "error": 1, 150 | "failed": 1, 151 | "passed": 5, 152 | "skipped": 1, 153 | "tests": 8, 154 | "time": 16.082687, 155 | }, 156 | }, 157 | ], 158 | "totals": { 159 | "cumulativeTime": 16.082687, 160 | "error": 1, 161 | "failed": 1, 162 | "passed": 5, 163 | "skipped": 1, 164 | "tests": 8, 165 | "time": 16.082687, 166 | }, 167 | } 168 | `) 169 | }) 170 | 171 | test('should reject XML with DOCTYPE declarations (XXE protection)', () => { 172 | const maliciousXml = ` 173 | 175 | ]> 176 | 177 | 178 | ` 179 | 180 | const result = parseJUnitXML(maliciousXml) 181 | expect(result.success).toBe(false) 182 | expect(result).toMatchObject({ 183 | success: false, 184 | error: expect.stringContaining( 185 | 'XML contains potentially malicious DOCTYPE or ENTITY declarations' 186 | ) 187 | }) 188 | }) 189 | 190 | test('should reject oversized XML content', () => { 191 | const largeContent = 'x'.repeat(11 * 1024 * 1024) 192 | const xml = `` 193 | 194 | const result = parseJUnitXML(xml) 195 | expect(result.success).toBe(false) 196 | expect(result).toMatchObject({ 197 | success: false, 198 | error: expect.stringContaining('XML content exceeds maximum size') 199 | }) 200 | }) 201 | 202 | test('should reject non-string input', () => { 203 | const nullResult = parseJUnitXML(null as unknown as string) 204 | expect(nullResult.success).toBe(false) 205 | expect(nullResult).toMatchObject({ 206 | success: false, 207 | error: expect.stringContaining('XML content must be a string') 208 | }) 209 | 210 | const undefinedResult = parseJUnitXML(undefined as unknown as string) 211 | expect(undefinedResult.success).toBe(false) 212 | expect(undefinedResult).toMatchObject({ 213 | success: false, 214 | error: expect.stringContaining('XML content must be a string') 215 | }) 216 | 217 | const numberResult = parseJUnitXML(123 as unknown as string) 218 | expect(numberResult.success).toBe(false) 219 | expect(numberResult).toMatchObject({ 220 | success: false, 221 | error: expect.stringContaining('XML content must be a string') 222 | }) 223 | }) 224 | 225 | test('should sanitize property names to prevent prototype pollution', () => { 226 | const xml = ` 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | ` 237 | 238 | const result = expectSuccess(parseJUnitXML(xml)) 239 | const suite = result.testsuites[0]! 240 | 241 | expect(suite.properties).toBeDefined() 242 | expect(suite.properties!['__proto__']).toBeUndefined() 243 | expect(suite.properties!['constructor']).toBeUndefined() 244 | expect(suite.properties!['prototype']).toBeUndefined() 245 | expect(suite.properties!['valid']).toBe('good') 246 | }) 247 | 248 | test('should truncate very long strings', () => { 249 | const longString = 'x'.repeat(60000) 250 | const xml = ` 251 | 252 | 253 | Long content 254 | 255 | 256 | ` 257 | 258 | const result = expectSuccess(parseJUnitXML(xml)) 259 | const testCase = result.testsuites[0]!.tests[0]! 260 | 261 | if (testCase.result.status !== 'failed') { 262 | throw new Error('Expected test result to be failed') 263 | } 264 | 265 | expect(testCase.result).toMatchObject({ 266 | status: 'failed', 267 | message: expect.stringMatching(/.*\.\.\.\[truncated\]/) 268 | }) 269 | expect(testCase.result.message!.length).toBeLessThanOrEqual(50020) 270 | }) 271 | 272 | test('should limit maximum number of properties', () => { 273 | const properties = Array.from( 274 | { length: 1001 }, 275 | (_, i) => `` 276 | ).join('') 277 | 278 | const xml = ` 279 | ${properties} 280 | 281 | ` 282 | 283 | const result = parseJUnitXML(xml) 284 | expect(result.success).toBe(false) 285 | expect(result).toMatchObject({ 286 | success: false, 287 | error: expect.stringContaining( 288 | 'Maximum properties count of 1000 exceeded' 289 | ) 290 | }) 291 | }) 292 | 293 | test('should handle deeply nested testsuites with depth limit', () => { 294 | let xml = '' 295 | for (let i = 0; i < 25; i++) { 296 | xml += `` 297 | } 298 | xml += '' 299 | for (let i = 0; i < 25; i++) { 300 | xml += '' 301 | } 302 | xml += '' 303 | 304 | const result = parseJUnitXML(xml) 305 | expect(result.success).toBe(false) 306 | expect(result).toMatchObject({ 307 | success: false, 308 | error: expect.stringContaining('Maximum nesting depth of 20 exceeded') 309 | }) 310 | }) 311 | 312 | test('should safely parse numeric values', () => { 313 | const xml = ` 314 | 315 | 316 | 317 | 318 | 319 | ` 320 | 321 | const result = expectSuccess(parseJUnitXML(xml)) 322 | const suite = result.testsuites[0]! 323 | 324 | expect(suite.tests[0]!.time).toBe(0) 325 | expect(suite.tests[1]!.time).toBe(0) 326 | expect(suite.tests[2]!.time).toBe(0) 327 | 328 | expect(suite.totals.tests).toBe(3) 329 | expect(suite.totals.passed).toBe(3) 330 | expect(suite.totals.time).toBe(0) 331 | }) 332 | }) 333 | 334 | describe('JUnit XML File System Ingestion', () => { 335 | const testDir = `test-temp` 336 | 337 | test('should ingest single XML file using ingestFile', () => { 338 | const result = expectSuccess( 339 | ingestFile('src/__test-fixtures__/junit-basic.xml') 340 | ) 341 | 342 | expect(result.testsuites).toMatchInlineSnapshot(` 343 | [ 344 | { 345 | "name": "Tests.Registration", 346 | "properties": undefined, 347 | "suites": undefined, 348 | "systemErr": undefined, 349 | "systemOut": undefined, 350 | "tests": [ 351 | { 352 | "classname": "Tests.Registration", 353 | "name": "testCase1", 354 | "properties": undefined, 355 | "result": { 356 | "status": "passed", 357 | }, 358 | "systemErr": undefined, 359 | "systemOut": undefined, 360 | "time": 2.113871, 361 | }, 362 | { 363 | "classname": "Tests.Registration", 364 | "name": "testCase2", 365 | "properties": undefined, 366 | "result": { 367 | "status": "passed", 368 | }, 369 | "systemErr": undefined, 370 | "systemOut": undefined, 371 | "time": 1.051, 372 | }, 373 | { 374 | "classname": "Tests.Registration", 375 | "name": "testCase3", 376 | "properties": undefined, 377 | "result": { 378 | "status": "passed", 379 | }, 380 | "systemErr": undefined, 381 | "systemOut": undefined, 382 | "time": 3.441, 383 | }, 384 | ], 385 | "totals": { 386 | "cumulativeTime": 6.605871, 387 | "error": 0, 388 | "failed": 0, 389 | "passed": 3, 390 | "skipped": 0, 391 | "tests": 3, 392 | "time": 6.605871, 393 | }, 394 | }, 395 | { 396 | "name": "Tests.Authentication", 397 | "properties": undefined, 398 | "suites": [ 399 | { 400 | "name": "Tests.Authentication.Login", 401 | "properties": undefined, 402 | "suites": undefined, 403 | "systemErr": undefined, 404 | "systemOut": undefined, 405 | "tests": [ 406 | { 407 | "classname": "Tests.Authentication.Login", 408 | "name": "testCase4", 409 | "properties": undefined, 410 | "result": { 411 | "status": "passed", 412 | }, 413 | "systemErr": undefined, 414 | "systemOut": undefined, 415 | "time": 2.244, 416 | }, 417 | { 418 | "classname": "Tests.Authentication.Login", 419 | "name": "testCase5", 420 | "properties": undefined, 421 | "result": { 422 | "status": "passed", 423 | }, 424 | "systemErr": undefined, 425 | "systemOut": undefined, 426 | "time": 0.781, 427 | }, 428 | { 429 | "classname": "Tests.Authentication.Login", 430 | "name": "testCase6", 431 | "properties": undefined, 432 | "result": { 433 | "status": "passed", 434 | }, 435 | "systemErr": undefined, 436 | "systemOut": undefined, 437 | "time": 1.331, 438 | }, 439 | ], 440 | "totals": { 441 | "cumulativeTime": 4.356, 442 | "error": 0, 443 | "failed": 0, 444 | "passed": 3, 445 | "skipped": 0, 446 | "tests": 3, 447 | "time": 4.356, 448 | }, 449 | }, 450 | ], 451 | "systemErr": undefined, 452 | "systemOut": undefined, 453 | "tests": [ 454 | { 455 | "classname": "Tests.Authentication", 456 | "name": "testCase7", 457 | "properties": undefined, 458 | "result": { 459 | "status": "passed", 460 | }, 461 | "systemErr": undefined, 462 | "systemOut": undefined, 463 | "time": 2.508, 464 | }, 465 | { 466 | "classname": "Tests.Authentication", 467 | "name": "testCase8", 468 | "properties": undefined, 469 | "result": { 470 | "status": "passed", 471 | }, 472 | "systemErr": undefined, 473 | "systemOut": undefined, 474 | "time": 1.230816, 475 | }, 476 | { 477 | "classname": "Tests.Authentication", 478 | "name": "testCase9", 479 | "properties": undefined, 480 | "result": { 481 | "body": "", 482 | "message": "Assertion error message", 483 | "status": "failed", 484 | "type": "AssertionError", 485 | }, 486 | "systemErr": undefined, 487 | "systemOut": undefined, 488 | "time": 0.982, 489 | }, 490 | ], 491 | "totals": { 492 | "cumulativeTime": 9.076816, 493 | "error": 0, 494 | "failed": 1, 495 | "passed": 5, 496 | "skipped": 0, 497 | "tests": 6, 498 | "time": 9.076816, 499 | }, 500 | }, 501 | ] 502 | `) 503 | }) 504 | 505 | test('should handle file not found error', () => { 506 | const result = ingestFile('nonexistent.xml') 507 | expect(result.success).toBe(false) 508 | expect(result).toMatchObject({ 509 | success: false, 510 | error: expect.stringContaining('ENOENT') 511 | }) 512 | }) 513 | 514 | test('should ingest directory with XML files using ingestDir', () => { 515 | try { 516 | mkdirSync(testDir, { recursive: true }) 517 | 518 | const minimalXml = ` 519 | 520 | 521 | 522 | ` 523 | 524 | const anotherXml = ` 525 | 526 | 527 | 528 | 529 | ` 530 | 531 | writeFileSync(join(testDir, 'test1.xml'), minimalXml) 532 | writeFileSync(join(testDir, 'test2.xml'), anotherXml) 533 | writeFileSync(join(testDir, 'ignore.txt'), 'not xml') 534 | 535 | const result = expectSuccess(ingestDir(testDir)) 536 | 537 | expect(result.testsuites).toHaveLength(2) 538 | expect(result.totals.tests).toBe(3) 539 | expect(result.totals.time).toBe(3.0) 540 | } finally { 541 | rmSync(testDir, { recursive: true, force: true }) 542 | } 543 | }) 544 | 545 | test('should handle empty directory', () => { 546 | try { 547 | mkdirSync(testDir, { recursive: true }) 548 | 549 | const result = expectSuccess(ingestDir(testDir)) 550 | 551 | expect(result.testsuites).toHaveLength(0) 552 | expect(result.totals.tests).toBe(0) 553 | } finally { 554 | rmSync(testDir, { recursive: true, force: true }) 555 | } 556 | }) 557 | 558 | test('should handle directory not found error', () => { 559 | const result = ingestDir('non-existent-dir') 560 | expect(result.success).toBe(false) 561 | expect(result).toMatchObject({ 562 | success: false, 563 | error: expect.stringContaining('Failed to ingest directory') 564 | }) 565 | }) 566 | 567 | test('should handle path that is not a directory', () => { 568 | const result = ingestDir('src/__test-fixtures__/junit-basic.xml') 569 | expect(result.success).toBe(false) 570 | expect(result).toMatchObject({ 571 | success: false, 572 | error: expect.stringContaining('is not a directory') 573 | }) 574 | }) 575 | 576 | test('should handle multi-root XML documents', () => { 577 | try { 578 | mkdirSync(testDir, { recursive: true }) 579 | 580 | const multiRootXml = ` 581 | 582 | 583 | 584 | 585 | 586 | 587 | ` 588 | 589 | writeFileSync(join(testDir, 'multi-root.xml'), multiRootXml) 590 | 591 | const result = expectSuccess(ingestFile(join(testDir, 'multi-root.xml'))) 592 | 593 | expect(result.testsuites).toHaveLength(2) 594 | expect(result.testsuites[0]!.name).toBe('Suite1') 595 | expect(result.testsuites[1]!.name).toBe('Suite2') 596 | expect(result.totals.tests).toBe(2) 597 | } finally { 598 | rmSync(testDir, { recursive: true, force: true }) 599 | } 600 | }) 601 | 602 | test('should combine totals correctly across multiple files', () => { 603 | try { 604 | mkdirSync(testDir, { recursive: true }) 605 | 606 | const xml1 = ` 607 | 608 | 609 | 610 | 611 | ` 612 | 613 | const xml2 = ` 614 | 615 | 616 | 617 | Failure message 618 | 619 | 620 | ` 621 | 622 | writeFileSync(join(testDir, 'passing.xml'), xml1) 623 | writeFileSync(join(testDir, 'failing.xml'), xml2) 624 | 625 | const result = expectSuccess(ingestDir(testDir)) 626 | 627 | expect(result.totals.tests).toBe(4) 628 | expect(result.totals.passed).toBe(3) 629 | expect(result.totals.failed).toBe(1) 630 | expect(result.totals.time).toBe(5.0) 631 | } finally { 632 | rmSync(testDir, { recursive: true, force: true }) 633 | } 634 | }) 635 | 636 | test('should preserve time discrepancies between XML time and cumulative time (jest-junit example)', async () => { 637 | const result = expectSuccess( 638 | ingestFile('src/__test-fixtures__/jest-junit.xml') 639 | ) 640 | 641 | expect(result.totals.time).toBe(0.542) 642 | expect(result.totals.cumulativeTime).toBe(0.412) 643 | 644 | const metricsSuite = result.testsuites.find( 645 | (s) => s.name === 'src/metrics-submitter.test.ts' 646 | ) 647 | expect(metricsSuite).toBeDefined() 648 | expect(metricsSuite!.totals.time).toBe(0.265) 649 | expect(metricsSuite!.totals.cumulativeTime).toBe(0.153) 650 | 651 | const junitSuite = result.testsuites.find( 652 | (s) => s.name === 'src/junit-parser.test.ts' 653 | ) 654 | expect(junitSuite).toBeDefined() 655 | expect(junitSuite!.totals.time).toBe(0.061) 656 | expect(junitSuite!.totals.cumulativeTime).toBe(0.019) 657 | 658 | const mainSuite = result.testsuites.find( 659 | (s) => s.name === 'src/main.test.ts' 660 | ) 661 | expect(mainSuite).toBeDefined() 662 | expect(mainSuite!.totals.time).toBe(0.05) 663 | expect(mainSuite!.totals.cumulativeTime).toBe(0.008) 664 | 665 | const genSuite = result.testsuites.find( 666 | (s) => s.name === 'src/metrics-generator.test.ts' 667 | ) 668 | expect(genSuite).toBeDefined() 669 | expect(genSuite!.totals.time).toBe(0.036) 670 | expect(genSuite!.totals.cumulativeTime).toBe(0.004) 671 | }) 672 | 673 | test('should require time attribute on testsuites element', () => { 674 | const xmlWithoutTime = ` 675 | 676 | 677 | 678 | 679 | ` 680 | 681 | const result = parseJUnitXML(xmlWithoutTime) 682 | expect(result.success).toBe(false) 683 | if (result.success) { 684 | throw new Error('Expected parsing to fail but it succeeded') 685 | } 686 | expect(result.error).toBe( 687 | 'testsuites element is missing required time attribute' 688 | ) 689 | }) 690 | }) 691 | --------------------------------------------------------------------------------