@teamscale/coverage-collector 1.0.0-beta.6 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,43 +9,10 @@ files in the [Teamscale Simple Coverage Format](https://docs.teamscale.com/refer
9
9
  The Teamscale JavaScript Profiler consists of this Coverage Collector and the
10
10
  [JavaScript Instrumenter](https://www.npmjs.com/package/@teamscale/javascript-instrumenter).
11
11
  More details on using them (in combination) can be found
12
- in the [Teamscale Documentation](https://docs.teamscale.com/howto/recording-test-coverage-for-javascript/).
12
+ in the [Teamscale Documentation](https://docs.teamscale.com/howto/setting-up-profiler-tga/javascript/).
13
13
 
14
14
  The JavaScript Coverage Collector starts a server process that listens for
15
15
  code coverage information from manually or automatically exercised (tested)
16
16
  JavaScript applications. The server also handles source maps to map coverage
17
17
  information back to the original source code.
18
18
 
19
- ## Building
20
-
21
- The Collector is written in TypeScript/JavaScript. For building and running it,
22
- NodeJs (>= v16) and pnpm are needed as prerequisites.
23
-
24
- ```
25
- pnpm clean
26
- pnpm install
27
- pnpm build
28
- ```
29
-
30
- ## Running the Collector
31
-
32
- There are several options to run the Collector. For example, via `pnpm` by running
33
-
34
- ```
35
- pnpm collector --port 54678 --dump-to-file=./coverage.simple
36
- ```
37
-
38
- or via `npx` by running
39
-
40
- ```
41
- npx @teamscale/coverage-collector --port 54678 --dump-to-file=./coverage.simple
42
- ```
43
-
44
- Note that NodeJs applications (as the Collector) can only access a limited
45
- amount of RAM by default. Ensure to increase the 'max old space' as needed,
46
- for example, by setting a corresponding environment variable.
47
-
48
- ```
49
- export NODE_OPTIONS="$NODE_OPTIONS --max-old-space-size=8192"
50
- ```
51
-
package/dist/main.mjs ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import e from"form-data";import*as t from"fs";import n from"fs";import r from"axios";import{inspect as i}from"util";import a from"url";import o from"bunyan";import"dotenv/config";import*as s from"node:process";import c from"node:process";import{createHash as l}from"node:crypto";import u from"node-cache";import d from"path";import{WebSocketServer as f}from"ws";import p from"express";import{mkdirp as m}from"mkdirp";const h=`v2025.2`;var g=class extends Error{};function _(t){let r=new e;return r.append(`report`,n.readFileSync(t),`coverage.simple`),r}async function v(e,t,n,r,i){return y(e,t,n,r,i)}async function y(e,t,n,a,o){try{o.debug(`Requesting via URL: `+e);let i;if(a===r.get)i=await r.get(e,n);else if(a===r.put)i=await r.put(e,t,n);else if(a===r.post)i=await r.post(e,t,n);else throw Error(`Unsupported request function: ${a}`);return o.debug(`Request finished with code ${i.status}.`),i.data}catch(t){if(r.isAxiosError(t)){let n;throw t.message&&o.error(`Request error ${t.status??`UNDEFINED`}: ${t.message}\n\tRequested URL: ${e}`),t.response?(o.error(`Request error response data:`,t.response.data),o.error(`Request error response status:`,t.response.status),o.debug(`Request error response headers:`,JSON.stringify(t.response.headers)),n=`Request failed with status ${t.response.status}: ${t.response.data??``}`):n=t.request?`No response received for the request. `+(t.message??t.code):`Request setup failed.`,new g(`Something went wrong when interacting with the API: ${n}`)}throw new g(`Something went wrong: ${i(t)}`)}}function ee(e,t){return b(e,{"Content-Type":`multipart/form-data; boundary=${t.getBoundary()}`})}function b(e,t){return{auth:{username:e.teamscaleUser??`no username provided`,password:e.teamscaleAccessToken??`no password provided`},headers:{Accept:`*/*`,...t},timeout:15e3,proxy:x(e)}}function x(e){let t=e.httpProxy;if(!t)return;let n=new a.URL(t),r={protocol:n.protocol.replace(`:`,``),host:n.hostname,port:+n.port};return n.username&&n.password&&(r.auth={username:n.username,password:n.password}),r}function S(e){return e?.replace(/\/$/,``)}var te=class extends URLSearchParams{addIfDefined(e,t){t?this.append(e,t):this.delete(e)}};async function C(e,t){if(e.teamscaleAccessToken===void 0)return t.error(`No valid access token provided for accessing the Teamscale server.`),!1;if(e.teamscaleUser===void 0)return t.error(`No valid user name provided for accessing the Teamscale server.`),!1;let n=`${S(e.teamscaleServerUrl)}/api/${h}/auth/login/access-key`;try{return await y(n,JSON.stringify({username:e.teamscaleUser,password:e.teamscaleAccessToken,stayLoggedIn:!1}),b(e,{"Content-Type":`application/json`}),r.post,t),!0}catch(e){return t.error(`Teamscale authentication failed.`,e),!1}}async function ne(e,t,n,r,i){if(!(e.teamscaleAccessToken&&e.teamscaleUser&&e.teamscaleServerUrl))throw new g(`API key and user name must be configured!`);if(!e.teamscaleProject&&!e.teamscalePartition)throw new g(`Teamscale project and partition must be configured!`);if(r===0)return!1;t.debug(`Preparing upload to Teamscale`);let a=_(n);return await re(e,ie(e,i),a,t),!0}async function re(e,t,n,i){await v(`${S(e.teamscaleServerUrl)}/api/${h}/projects/${e.teamscaleProject}/external-analysis/session/auto-create/report?${t.toString()}`,n,ee(e,n),r.post,i)}function ie(e,t){let n=new te;return n.addIfDefined(`format`,`SIMPLE`),n.addIfDefined(`message`,e.teamscaleMessage),n.addIfDefined(`repository`,e.teamscaleRepository),ae(t)?n.addIfDefined(`t`,t):n.addIfDefined(`revision`,t),n.addIfDefined(`partition`,e.teamscalePartition),n}function ae(e){let t=e.split(`:`);return t.length===2?t[1].toUpperCase()===`HEAD`?!0:/^\d+$/.test(t[1]):t.length===1?/^\d+$/.test(e):!1}async function oe(e,t,n,r,i){if(!(e.artifactoryAccessToken||e.artifactoryUser&&e.artifactoryPassword))throw new g(`API key or user name and password must be configured!`);return r===0?!1:(t.debug(`Preparing upload to Artifactory`),await w(e,_(n),t,i),!0)}async function w(e,t,n,i){let a=i.split(`:`);if(a.length!==2)throw new g(`The commit "${i}" does not contain a branch and timestamp; this is needed for the Artifactory upload. Please use the format "branch:timestamp".`);if(a[0].length===0)throw new g(`The branch of the commit "${i}" is empty. Please use the format "branch:timestamp".`);let o=`${S(e.artifactoryServerUrl)}/uploads/${a[0]}/${a[1]}`;o+=`/${e.teamscalePartition}/simple`,e.artifactoryPathSuffix!==void 0&&(o=`${o}/${e.artifactoryPathSuffix}`),o=`${o}/report.simple`,await v(o,t,T(e,t),r.put,n)}function T(e,t){let n=x(e);return e.artifactoryAccessToken?{headers:{Accept:`*/*`,"X-JFrog-Art-Api":e.artifactoryAccessToken,"Content-Type":`multipart/form-data; boundary=${t.getBoundary()}`},proxy:n}:{auth:{username:e.artifactoryUser??`no Artifactory username provided`,password:e.artifactoryPassword??`no Artifactory access token provided`},headers:{Accept:`*/*`,"Content-Type":`multipart/form-data; boundary=${t.getBoundary()}`},proxy:n}}const E=Date.now();var D=class{static startRegularCollectorProcesses(e,t){let n=new Map,r=new Map,i=async(e,n,r,i)=>{let a=n.get(e)??E;if(Date.now()-a>r){try{await i()}catch(e){t.error(`Regular collector action failed with an error: `+e?.message),t.error(e)}n.set(e,Date.now())}},a=setInterval(async()=>{for(let t of e.getApplicationIDs())await i(`config-update-${t}`,n,1e3*53,async()=>{await e.refreshApplicationConfiguration(t)});for(let n of e.getApplicationIDs()){let a=e.getAppConfiguration(n).dumpAfterMins??10;await i(`coverage-dump-${n}`,r,1e3*60*a,async()=>{await this.dumpCoverage(e,t,n)})}},1e3*31);return e.addBeforeConfigUpdateCallback(async(e,n)=>{t.info(`Dumping coverage before config update for application ${e}.`),await this.dumpCoverage(n,t,e)}),t.info(`Started the regular remote configuration refresh and coverage dump.`),{stop:()=>{a&&clearInterval(a)}}}static async dumpCoverage(e,t,n){let r=n?[n]:e.getApplicationIDs(),i=new Date,a=!1,o=!1;for(let n of r){let r=e.getAppConfiguration(n),s=this.determineCoverageTargetFolder(n,r),c=!(r.keepCoverageFiles??r.dumpToFolder!==void 0);try{let l=e.dumpToSimpleCoverageFile(s,i,n);a=l.hadCoverageToDump||a;for(let{simpleCoverageFile:e,simpleCoverageFileLines:i,commit:a}of l.details)try{(r.teamscaleServerUrl||r.artifactoryServerUrl)&&(o=await this.uploadCoverage(r,e,i,a,t,c)||o)}catch(e){e instanceof g?t.error(`Coverage upload failed for application ${n} and commit ${a}.\nThe coverage files on disk (inside the folder "${s}") were not deleted. You can still upload them manually. Error: ${e.message}`):t.error(`Coverage dump failed`,e)}}catch(e){t.error(`Dumping coverage failed for application ${n}`,e)}}o&&t.info(`Uploaded coverage for timestamp ${i.toISOString()} to a remote server.`),a||t.info(`Coverage dump request was processed successfully; no new coverage to dump found.`)}static async uploadCoverage(e,t,r,i,a,o){let s=!1;return e.teamscaleServerUrl&&(e.teamscaleProject??``).length>0&&(s=await ne(e,a,t,r,i)),e.artifactoryServerUrl&&(s||=await oe(e,a,t,r,i)),o&&n.unlinkSync(t),s}static determineCoverageTargetFolder(e,t){let r=`${t.dumpFolder}/${se(t.dumpToFolder)}${e}`;return n.mkdirSync(r,{recursive:!0}),r}};function se(e){return e===void 0||e.trim().length===0?``:(e=e.trim().replace(/^\/+/g,``),e=e.replace(/\\/g,`/`),e=e.replace(/[^a-zA-Z0-9\-_/]/g,`_`),e.endsWith(`/`)?e:`${e}/`)}var ce=class{write(e){let t;t=e.level===o.ERROR?console.error:e.level===o.WARN?console.warn:console.log,t.apply(this,[`[${e.time.toISOString()}] ${o.nameFromLevel[e.level].toUpperCase()}: ${e.msg}`])}},le=class{outputStream;constructor(e){this.outputStream=e}write(e){this.outputStream.write(`[${e.time.toISOString()}] ${o.nameFromLevel[e.level].toUpperCase()}: ${e.msg}\n`)}end(){this.outputStream.close()}},O=class extends Error{},k=class extends Error{},A=class{static require(e,t){if(!e)throw new O(t)}static requireDefined(e,t){if(e||typeof e==`number`||typeof e==`boolean`||typeof e==`string`||e instanceof String)return e;throw t?new O(t):new O(`Reference must be defined.`)}static requireNonEmpty(e,t){if(this.requireDefined(e),e.length===0)throw new O(t);return e}static requireStringPattern(e,t,n){if(this.requireDefined(e),this.requireDefined(t),!e.match(t))throw new O(n);return e}};function ue(e,t){return t.startsWith(e)?t.substring(e.length):t}const j={order:999,title:`Application`};function de(e){if(e.type===`int`||e.type===`string`)return 1;if(e.type===`bool`)return 0;if(e.type===`string[]`)return 1/0;throw new k(`Unsupported parameter type "${e.type}" for parameter "${e.longParameter}".`)}function M(e){return e.replace(/^-+/,``).replace(/-([a-z])/g,(e,t)=>t.toUpperCase())}function fe(e){return e.replace(/^--/,``).toUpperCase().replace(/-/g,`_`)}var pe=class e{constructor(){this.parameters=new Map,this.shortToLongParameterMap=new Map,this.sequentialParameters=[],this.argumentChecks=[]}addParameter(e,t,n,r={}){let i=M(t),a=fe(t);this.describeParameter(i,{parameterId:i,longParameter:t,type:n,shortParameter:e,help:r.help??``,default:r.default,internal:r.internal??!1,group:r.group,environmentVariableName:a})}addArgumentCheck(e){this.argumentChecks.push(e)}addRequiredArgumentFor(e){let t=this.lookupParameter(e);if(t===void 0)throw new k(`Unknown parameter "${e}".`);if(t.default!==void 0)throw new k(`Parameter "${e}" is required, but has a default value.`);this.argumentChecks.push(n=>{if(n[e]===void 0)return`Missing required argument for parameter "${t.longParameter}"\n\tParameter description: ${t.help}`})}describeParameter(e,t){if(this.parameters.set(e,t),t.shortParameter){let n=t.shortParameter.replace(/^-/,``).trim().toLowerCase();if(n.length<1||n.length>3)throw new k(`Short parameter "${n}" is invalid.`);let r=this.shortToLongParameterMap.get(n);if(r!==void 0&&r!==e)throw new k(`Short parameter "${n}" is already mapped to parameter "${r}".`);this.shortToLongParameterMap.set(n,t.parameterId)}t.longParameter.startsWith(`--`)||this.sequentialParameters.push(e)}lookupParameter(e){let t=this.parameters.get(e);if(t===void 0){let n=this.shortToLongParameterMap.get(e);n!==void 0&&(t=this.parameters.get(n))}return t}getParameters(){return Array.from(this.parameters.values())}getArgumentChecks(){return this.argumentChecks}getSequentialParameters(){return this.sequentialParameters}clone(){let t=new e;for(let e of this.parameters.values())t.describeParameter(e.parameterId,e);for(let e of this.argumentChecks)t.addArgumentCheck(e);return t}addAllOf(e){for(let t of e.getParameters())this.describeParameter(t.parameterId,t);for(let t of e.getArgumentChecks())this.addArgumentCheck(t)}};function me(e,t){let n={},r,i=[],a=0;function o(){if(r){if(a<1/0&&a>0)throw new k(`Missing value for parameter "${r.longParameter}".`);if(r.type===`bool`)n[r.parameterId]=!0,r=void 0;else if(r.type===`string[]`){let e=n[r.parameterId],t=P(i.join(`,`));I(e)?n[r.parameterId]=e.concat(t):n[r.parameterId]=t}else{if(i.length!==1)throw new k(`Invalid value for parameter "${r.longParameter}": Expected a single value, got ${i.length}.`);n[r.parameterId]=F(i[0],r)}r=void 0,i=[]}}for(let n of e){if(n.startsWith(`-`)){o();let e=M(n);if(r=t.lookupParameter(e),!r)throw new k(`Unknown configuration parameter: ${n}`);a=de(r)}else{if(!r)throw new k(`Unexpected or unnamed argument, or unknown parameter '${n}' supplied. Please specify a parameter name (for example, --input) for each argument.`);i.push(n),a--}a===0&&o()}o();for(let e of t.getParameters())if(n[e.parameterId]===void 0){n[e.parameterId]=e.default;let t=c.env[e.environmentVariableName];t&&!e.disableEnvironmentVariableOverride&&(n[e.parameterId]=F(t,e))}return n}function N(e){return e=e.trim(),e.startsWith(`"`)&&e.endsWith(`"`)||e.startsWith(`'`)&&e.endsWith(`'`)||e.startsWith("`")&&e.endsWith("`")?e.substring(1,e.length-1):e}function P(e){return e?e.split(`,`).map(e=>N(e)):[]}function F(e,t){if(t.type===`int`){let n=parseInt(e,10);if(isNaN(n))throw new k(`Invalid value for parameter "${t.longParameter}": Expected an integer, got "${e}".`);return n}else if(t.type===`bool`){let n=e.toLowerCase();if(n===`true`||n===`1`)return!0;if(n===`false`||n===`0`)return!1;throw new k(`Invalid value for parameter "${t.longParameter}": Expected a boolean, got "${e}".`)}else if(t.type===`string`)return N(e);else if(t.type===`string[]`)return P(e);else throw new k(`Unsupported parameter type "${t.type}" for parameter "${t.longParameter}".`)}function I(e){return Array.isArray(e)&&e.every(e=>typeof e==`string`)}function L(e,t,n=!1){let r=[];t&&(r.push(t),r.push(``)),n||r.push(`Usage:`);let i=he(e);for(let e of i){e.groupTitle&&(r.push(``),r.push(`# ${e.groupTitle}`),r.push(``)),e.groupHint&&(r.push(R(e.groupHint,80)),r.push(``));for(let t of e.parameters){if(t.internal)continue;function e(){let e=t.environmentVariableName;return t.type===`bool`?``:t.type===`string[]`?` ${e}, .., ${e}`:` ${e}`}let i=[];n?i.push(`Parameter "${t.parameterId}" (${t.type})`):(t.shortParameter&&i.push(`${t.shortParameter}${e()}`),i.push(`${t.longParameter}${e()}`));let a=z(R(t.help,80)),o=t.default?`Default: ${t.default}`:void 0;r.push(` ${i.join(`, `)}`),a&&r.push(`\t${a}`),o&&r.push(`\t${o}`)}}r.push(``),console.log(r.join(`
3
+ `))}function R(e,t){e=e.replace(/\n/g,` `);let n=e.split(` `),r=[],i=``;for(let e of n)i.length+e.length+1>t&&(r.push(i),i=``),i.length>0&&(i+=` `),i+=e;return i.length>0&&r.push(i),r.join(`
4
+ `)}function z(e){if(e)return e.replace(/\n/g,`
5
+ `)}function he(e){let t=new Map,n=new Map,r=new Map;for(let i of e.getParameters()){let e=i.group?.title;i.group&&n.set(e,i.group.order),i.group?.hints&&r.set(e,i.group?.hints);let a=t.get(e)||[];a.push(i),t.set(e,a)}return Array.from(t.entries()).sort(([e],[t])=>(n.get(e)??Number.MAX_VALUE)-(n.get(t)??Number.MAX_VALUE)).map(([e,t])=>({groupTitle:e,groupHint:r.get(e),parameters:t.sort((e,t)=>e.longParameter.localeCompare(t.longParameter))}))}function B(e,t,n){let r=!0;n||=e=>{console.error(e)};for(let i of e.getArgumentChecks()){let e=i(t);e&&(n(`Invalid configuration: `+e),r=!1)}return r}function ge(e,t,n,r){n??=c.argv.slice(2);let i=e.clone();i.addParameter(`-h`,`--help`,`bool`,{help:`Show this help message and exit.`,group:j,disableEnvironmentVariableOverride:!0}),i.addParameter(`-v`,`--version`,`bool`,{help:`Show the version number and exit.`,group:j,disableEnvironmentVariableOverride:!0});let a=me(n,i);return a.version&&(console.log(`${t.name} v${t.version}`),c.exit(0)),a.help&&(L(i,t.about),c.exit(0)),r&&r(a),B(i,a)||(console.error(`
6
+ Arguments were missing or invalid. Please check the --help for more information. Exiting with error code 1.`),c.exit(1)),a}function _e(e,t){let n=e.clone();return n.addAllOf(t),n}var V=class e{constructor(e,t,n,r){this.baseConfiguration=A.requireDefined(n),this.redefinableParameters=A.requireDefined(t),this.allParameters=A.requireDefined(e),this.overwrites=r??{},this.hash=void 0}get(e){return this.assertParameterExists(e),this.overwrites[e]??this.baseConfiguration[e]}set(e,t){if(this.redefinableParameters.lookupParameter(e)===void 0)throw Error(`Unknown configuration parameter: ${e}`);this.overwriteConfig(e,t),this.hash=void 0}overwriteConfig(e,t){let n=M(e);if(this.assertParameterExists(n),t===void 0){delete this.overwrites[n];return}this.overwrites[n]=this.castConfigArgument(n,t),this.hash=void 0}castConfigArgument(e,t){let n=this.getParameter(e).type;if(n===`int`){if(typeof t==`number`)return Math.floor(t);if(typeof t==`string`)return parseInt(t,10);if(typeof t==`boolean`)return t?1:0}else if(n===`bool`){if(typeof t==`boolean`)return t;if(typeof t==`string`)return t.toLowerCase()===`true`;if(typeof t==`number`)return t!==0}else if(n===`string[]`){if(t===void 0||t==null)return[];if(Array.isArray(t))return t.map(String);if(typeof t==`string`)return t.split(`,`).map(e=>e.trim())}else return String(t);throw Error(`Cannot cast value of type ${typeof t} to ${n}`)}assertParameterExists(e){if(this.allParameters.lookupParameter(e)===void 0)throw Error(`Unknown configuration parameter: ${e}`)}getParameter(e){let t=this.redefinableParameters.lookupParameter(e);if(t===void 0)throw Error(`Unknown configuration parameter: ${e}`);return t}getHash(){if(this.hash===void 0){let e=l(`sha256`);for(let[t,n]of Object.entries(this.baseConfiguration))e.update(t),e.update(String(n));for(let[t,n]of Object.entries(this.overwrites))e.update(t),e.update(String(n));this.hash=e.digest(`hex`)}return this.hash}copy(){let t=JSON.parse(JSON.stringify(this.overwrites));return new e(this.allParameters,this.redefinableParameters,this.baseConfiguration,t)}};function H(e){return new Proxy(e,{get(t,n){return e.get(n.toString())},set(t,n,r){return e.set(n.toString(),r),!0}})}const U={order:1,title:`Collector Connectivity`},W={order:1,title:`Teamscale Server`},G={order:2,title:`Upload to Teamscale`,hints:"NOTE: We generally recommend to set these parameters per app during the instrumentation process, that is, on the instrumenter side, for example, by setting `--config-id` or specifying single options via `--collector-option`. The following parameters are supposed to take effect if the application is instrumented with the `--coverage-target-from-collector` flag."},K={order:3,title:`Coverage Dumping`},q={order:4,title:`Upload to Artifactory`},J={order:5,title:`Logging Behavior`};function Y(){let e=new pe;function t(t,n,r,i){e.addParameter(t,n,r,i)}return t(`-k`,`--keep-coverage-files`,`bool`,{help:`Whether to keep the coverage files on disk after a successful upload to Teamscale.`,default:!1,group:K}),t(`-t`,`--dump-after-mins`,`int`,{help:`Dump the coverage information every N minutes.`,default:120,group:K}),t(void 0,`--artifactory-server-url`,`string`,{help:`Upload the coverage to the given Artifactory server URL. The URL may include a subpath on the artifactory server, e.g. https://artifactory.acme.com/my-repo/my/subpath`,group:q}),t(void 0,`--artifactory-user`,`string`,{help:`The user for uploading coverage to Artifactory. Only needed when not using the --artifactory-access-token option`,group:q}),t(void 0,`--artifactory-password`,`string`,{help:`The password for uploading coverage to Artifactory. Only needed when not using the --artifactory-access-token option`,group:q}),t(void 0,`--artifactory-access-token`,`string`,{help:`The access_token for uploading coverage to Artifactory.`,group:q}),t(void 0,`--artifactory-path-suffix`,`string`,{help:`(optional): The path within the storage location between the default path and the uploaded artifact.`,group:q}),t(void 0,`--teamscale-project`,`string`,{help:`The project ID to upload coverage to.`,group:G}),t(void 0,`--teamscale-partition`,`string`,{help:`The partition to upload coverage to.`,group:G}),t(void 0,`--teamscale-repository`,`string`,{help:`The repository to upload coverage for. Optional: Only needed when uploading via revision to a project that has more than one connector.`,group:G}),t(void 0,`--teamscale-message`,`string`,{help:`The commit message shown within Teamscale for the coverage upload.`,default:`JavaScript coverage upload`,group:G}),e}function ve(){let e=Y();function t(t,n,r,i){e.addParameter(t,n,r,i)}return t(`-p`,`--port`,`int`,{help:`The port to receive coverage information on.`,default:54678,group:U,disableEnvironmentVariableOverride:!0}),t(`-l`,`--log-to-file`,`string`,{help:`Log file`,default:`logs/collector-combined.log`,group:J,disableEnvironmentVariableOverride:!0}),t(`-e`,`--log-level`,`string`,{help:`Log level`,default:`info`,group:J,disableEnvironmentVariableOverride:!0}),t(`-j`,`--json-log`,`bool`,{help:`Additional JSON-like log file format.`,group:J,disableEnvironmentVariableOverride:!0}),t(`-c`,`--enable-control-port`,`int`,{help:`Enables the remote control API on the specified port (<=0 means "disabled").`,default:0,group:U}),t(`-s`,`--teamscale-server-url`,`string`,{help:`Upload the coverage to the given Teamscale server URL, for example, https://teamscale.dev.example.com:8080/production.`,group:W}),t(`-u`,`--teamscale-user`,`string`,{help:`The user for uploading coverage to Teamscale.`,group:W}),t(`-a`,`--teamscale-access-token`,`string`,{help:`The API key to use for uploading to Teamscale.`,group:W}),t(void 0,`--http-proxy`,`string`,{help:`(optional): The HTTP/HTTPS proxy address that should be used in the format: http://host:port/ or http://username:password@host:port/.`,group:U}),t(`-f`,`--dump-folder`,`string`,{help:`Target folder for coverage files.`,default:`./coverage`,group:K}),e.addArgumentCheck(e=>{if(e.teamscaleServerUrl&&(!e.teamscaleUser||!e.teamscaleAccessToken))return`The Teamscale user name and access token must be given if the Teamscale server URL is given.`}),e}function ye(){let e=Y();function t(t,n,r,i){e.addParameter(t,n,r,i)}function n(e,n,r){t(void 0,e,n,r)}return n(`--dump-to-folder`,`string`,{help:`Coverage should be dumped to a folder on the server that the collector is running on.
7
+ Specifies the name of the subfolder within the collector's dump folder (--dump-folder of the collector) where coverage files should be placed.`,group:K}),e}const X=new u({stdTTL:60,deleteOnExpire:!0});var be=class{static async queryConfiguration(e,t,n){if(!e.teamscaleServerUrl||!e.teamscaleAccessToken||!e.teamscaleUser)throw Error(`Access to Teamscale is not configured. Receiving profiler configurations is not possible. Please specify configuration arguments for --teamscale-server-url, --teamscale-user, and --teamscale-access-token`);let i=X.get(t);try{if(i)return n.debug(`Using cached configuration with id `+t),i;n.debug(`Requesting configuration with ID ${t} from Teamscale.`);let a=await y(`${S(e.teamscaleServerUrl)}/api/${h}/profiler-configurations/${t}`,void 0,b(e,{}),r.get,n);if(a===void 0)throw new k(`No configuration found with ID ${t}`);if(!a.configurationOptions||a.configurationOptions.trim().length===0)throw new k(`Configuration with ID "${t}" is empty. Please specify relevant configuration options.`);i=this.parseConfigurationStringIntoMap(a),X.set(t,i)}catch(e){throw Error(`Failed to retrieve configuration with ID ${t}: ${e.message}`,{cause:e})}return i}static parseConfigurationStringIntoMap(e){let t=new Map;return(e.configurationOptions??``).split(/[\r\n]+/).map(e=>e.trim()).filter(e=>e.length>0).filter(e=>!e.startsWith(`#`)).forEach(e=>{let n=e.split(`=`);if(n.length!==2)throw new k(`Invalid configuration line; expecting a valid key=value pair: ${e}`);t.set(n[0].trim(),n[1].trim())}),t}};function Z(){X.flushAll()}var Q=class{appId;commit;coveredLinesByFile;constructor(e,t){this.coveredLinesByFile=new Map,this.appId=e,this.commit=t}putLine(e,t){let n=this.coveredLinesByFile.get(e);n||(n=new Set,this.coveredLinesByFile.set(e,n)),n.add(t)}getCoverage(){function*e(e,t){for(let n of e)yield t(n)}return e(this.coveredLinesByFile.entries(),([e,t])=>({sourceFile:e,coveredLines:t}))}clear(){this.coveredLinesByFile.clear()}},xe=class e{coverageBuckets;configPerApp;validatedPerApp;configIdPerApp;logger;baseConfig;reconfigurableParameters;allParameters;beforeConfigUpdateCallbacks;constructor(e,t,n,r){this.coverageBuckets=new Map,this.configIdPerApp=new Map,this.configPerApp=new Map,this.validatedPerApp=new Map,this.logger=A.requireDefined(e),this.baseConfig=A.requireDefined(r),this.allParameters=A.requireDefined(t),this.reconfigurableParameters=A.requireDefined(n),this.beforeConfigUpdateCallbacks=[]}async setupCoverageBucket(e){this.getBucketFor(e);try{if(e.configId&&await this.changeAppConfiguration(e.appId,e.configId),!e.configOptions)return;let t=e.configOptions.split(/\r?\n/).map(e=>e.trim());for(let n of t){if(n.length===0||n.startsWith(`#`))continue;let t=n.split(`=`);if(t.length===1&&this.setConfigurationParameter(e.appId,t[0],void 0),t.length===2){let[n,r]=t;this.setConfigurationParameter(e.appId,n.trim(),r.trim())}else throw Error(`Invalid configuration option '${n}'. Expected format <key>=<value>.`)}}catch(e){this.logger.error(`Setting up a the configuration of a coverage bucket failed. Your profiler configuration seems to be invalid: `+e?.message)}}async changeAppConfiguration(e,t){let n=this.configIdPerApp.get(e);(!n||n!==t)&&await this.refreshApplicationConfiguration(e,t)}async refreshAllRemoteConfigurations(){Z();for(let e of this.getApplicationIDs())await this.refreshApplicationConfiguration(e)}async refreshApplicationConfiguration(e,t){let n=t??this.configIdPerApp.get(e);if(n){try{let t=await be.queryConfiguration(this.baseConfig,n,this.logger),r=this.getOrCreateAppConfig(e).copy();for(let[n,i]of t.entries())try{r.overwriteConfig(n,i)}catch(t){this.logger.error(`Failed to set configuration parameter ${n}=${i} for app ${e}: ${t}`)}let i=this.validatedPerApp.get(e),a=i!==void 0&&r.getHash()!==i;if(this.validateConfiguration(e,r),a)for(let t of this.beforeConfigUpdateCallbacks)await t(e,this);this.configPerApp.set(e,r)}catch(t){throw this.logger.error(`Failed to retrieve configuration for app ${e} with ID ${n}: ${t}`),this.logger.error(t),t}this.configIdPerApp.set(e,n)}}getOrCreateAppConfig(e){let t=this.configPerApp.get(e);return t||(t=new V(this.allParameters,this.reconfigurableParameters,this.baseConfig),this.configPerApp.set(e,t)),t}setConfigurationParameter(e,t,n){this.getOrCreateAppConfig(e).overwriteConfig(t,n)}validateConfiguration(e,t){if(t===void 0)throw Error(`Configuration for app "${e}" not found`);let n=this.validatedPerApp.get(e),r=t.getHash();n!==r&&(B(this.reconfigurableParameters,H(t),e=>this.logger.error(e)),this.validatedPerApp.set(e,r))}getAppConfiguration(e){let t=this.configPerApp.get(e);return t||(t=new V(this.allParameters,this.reconfigurableParameters,this.baseConfig),this.configPerApp.set(e,t)),H(t)}getBucketFor(e){let t=$(e.appId,e.commit),n=this.coverageBuckets.get(t);return n||(n=new Q(e.appId,e.commit),this.coverageBuckets.set(t,n)),t}getBucket(e,t){return this.coverageBuckets.get($(e,t))}getAppBuckets(e){return Array.from(this.coverageBuckets.values()).filter(t=>e===void 0||t.appId===e)}putCoverage(t,n,r,i){let a=e.normalizeSourceFileName(r),o=$(t,n),s=this.coverageBuckets.get(o);s||(s=new Q(t,n),this.coverageBuckets.set(o,s)),i.forEach(e=>s?.putLine(r,e)),this.logger.trace(`Mapped Coverage: ${t} ${n} ${a} ${i}`)}static normalizeSourceFileName(e){return ue(`webpack:///`,e.replace(`\\`,`/`))}dumpToSimpleCoverageFile(e,n,r){e=e.trim();let i=[],a=!1,o=r?[r]:this.getApplicationIDs();for(let r of o){let o=this.toSimpleCoverage(r);for(let[s,[c,l]]of o)if(a||=c>0,c>0){let a=this.prepareValidCoverageFileName(e,n,s,r);t.writeFileSync(a,l,{flag:`w`,encoding:`utf8`}),this.logger.debug(`Simple coverage with length ${l.length} dumped to file ${a}.`),i.push({appId:r,simpleCoverageFile:a,simpleCoverageFileLines:c,commit:s})}this.resetCoverage(r)}return{hadCoverageToDump:a,details:i}}resetCoverage(e){this.getAppBuckets(e).forEach(e=>e.clear())}prepareValidCoverageFileName(e,n,r,i){t.existsSync(e)||t.mkdirSync(e);let a=Ce(n);return i?d.join(e,Se(`coverage-${i}-${a}-${r}.simple`)):d.join(e,`coverage-${a}.simple`)}toSimpleCoverage(t){let n=new Map;for(let r of this.getAppBuckets(t)){let t=n.get(r.commit);t||(t=[],n.set(r.commit,t));let i=r.getCoverage();for(let n of i){t.push(e.normalizeSourceFileName(n.sourceFile));for(let e of n.coveredLines)t.push(String(e))}}let r=new Map;for(let[e,t]of n)r.set(e,[t.length,t.join(`
8
+ `)]);return r}getApplicationIDs(){return new Set(Array.from(this.coverageBuckets.values()).map(e=>e.appId))}getApplicationsWithConfig(e){return new Set(Array.from(this.coverageBuckets.values()).filter(t=>this.configIdPerApp.get(t.appId)===e).map(e=>e.appId))}discardCollectedCoverage(e){$?this.getAppBuckets(e).forEach(e=>e.clear()):this.coverageBuckets.clear()}addBeforeConfigUpdateCallback(e){this.beforeConfigUpdateCallbacks.push(e)}};function $(e,t){return`${e}-${t}`}function Se(e){return e.replace(/[^a-zA-Z0-9._-]/g,`_`)}function Ce(e){let t=e=>e.toString().padStart(2,`0`);return`${e.getFullYear()}-${t(e.getMonth()+1)}-${t(e.getDate())}-${t(e.getHours())}-${t(e.getMinutes())}-${t(e.getSeconds())}`}var we=class{internalSessionId;storage;appId;commitId;constructor(e){this.storage=A.requireDefined(e),this.internalSessionId=Math.random().toString(36).substring(2,15)+Math.random().toString(36).substring(2,15)}async setupBucketAndApplication(e){this.commitId=e.commit,this.appId=e.appId,await this.storage.setupCoverageBucket(e)}putLineCoverage(e,t,n){A.requireDefined(this.appId,`The application ID must be set before putting coverage.`),A.requireDefined(this.commitId,`The commit must be set before putting coverage.`);let r=[];for(let e=t;e<=n;e++)r.push(e);return this.storage.putCoverage(this.appId,this.commitId,e,r),r.length}get sessionId(){return this.internalSessionId}},Te=class{server;storage;logger;totalNumMessagesReceived;totalNumCoverageMessagesReceived;constructor(e,t,n){A.require(e>0&&e<65536,`Port must be valid (range).`),this.storage=A.requireDefined(t),this.logger=A.requireDefined(n),this.server=new f({port:e}),this.totalNumMessagesReceived=0,this.totalNumCoverageMessagesReceived=0}start(){return this.logger.info(`Starting server on port ${this.server?.options.port}.`),this.server?.on(`connection`,(e,t)=>{let n=new we(this.storage);this.logger.trace(`Connection from: ${t.socket.remoteAddress}`),e.on(`close`,e=>{n&&(n=null,this.logger.trace(`Closing with code ${e}`))}),e.on(`message`,e=>{this.totalNumMessagesReceived+=1,n&&Buffer.isBuffer(e)&&this.handleMessage(n,e)}),e.on(`error`,e=>{this.logger.error(`Error on server socket triggered.`,e)})}),{stop:()=>{this.server?.clients.forEach(e=>e.close()),this.server?.close(),this.server=null}}}async handleMessage(e,t){try{let n=t.toString(`utf8`,0,1);if(n===`b`){let n=t.toString(`utf-8`,1).trim(),r=Buffer.from(n,`base64`).toString(`utf8`),i=JSON.parse(r);this.logger.debug(`Received bucket definition: ${JSON.stringify(i)} for session ${e.sessionId}.`),await e.setupBucketAndApplication(i)}else if(n===`c`){this.totalNumCoverageMessagesReceived+=1;let n=this.handleCoverageMessage(e,t.subarray(1));n>0&&this.logger.debug(`Coverage for approximately ${n} lines processed for session ${e.sessionId}.`)}else this.logger.error(`Unknown message type: ${n}`)}catch(e){this.logger.error(`Error while processing message starting with ${t.toString(`utf8`,0,Math.min(250,t.length))}`),this.logger.error(e.message)}}handleCoverageMessage(e,t){let n=0,r=t.toString().split(/[\n;]/).map(e=>e.trim()),i=``;return r.forEach(t=>{if(t.startsWith(`@`))i=t.substring(1).trim();else if(i){let r=t.split(/,|-/).map(e=>Number.parseInt(e));r.length===1?n+=e.putLineCoverage(i,r[0],r[0]):r.length===2?n+=e.putLineCoverage(i,r[0],r[1]):this.logger.error(`Invalid range token: ${t}`)}}),n}getStatistics(){return{totalMessages:this.totalNumMessagesReceived,totalCoverageMessages:this.totalNumCoverageMessagesReceived}}},Ee=class{collectorStopCallback;constructor(e,t,n){this.config=e,this.storage=t,this.logger=n}start(){if(!this.config.enableControlPort)return{async stop(){}};let e=p();e.use(p.text({})),e.use(p.urlencoded({extended:!0}));let t=e.listen(this.config.enableControlPort);return e.post(`/stop/`,(e,t)=>this.handleStopCollectorReset(e,t)),e.post(`/refresh/`,(e,t)=>this.handleRefreshConfigs(e,t)),e.post(`/dump/`,(e,t)=>this.handleDumpPost(e,t)),e.post(`/dump/:configId`,(e,t)=>this.handleDumpPostForConfig(e,t)),e.post(`/reset`,(e,t)=>this.handleGlobalCoverageReset(e,t)),e.post(`/reset/:configId`,(e,t)=>this.handleCoverageResetForConfig(e,t)),this.logger.info(`Control server enabled at port ${this.config.enableControlPort}.`),{async stop(){return new Promise(e=>{t.close(()=>e())})}}}async handleRefreshConfigs(e,t){this.logger.info(`Remote configuration refresh requested via the control API.`),await this.storage.refreshAllRemoteConfigurations(),this.logger.info(`Refresh done.`),t.sendStatus(200)}async handleDumpPost(e,t){this.logger.info(`Dumping coverage requested via the control API.`),await D.dumpCoverage(this.storage,this.logger),t.sendStatus(200)}async handleDumpPostForConfig(e,t){return this.handleConfigScopedRequest(e,t,async e=>{this.logger.info(`Dumping coverage requested for config '${e}' via the control API.`);let t=this.storage.getApplicationsWithConfig(e);for(let e of t)await D.dumpCoverage(this.storage,this.logger,e)})}async handleGlobalCoverageReset(e,t){this.storage.discardCollectedCoverage(),this.logger.info(`Discarding collected coverage information as requested via the control API.`),t.sendStatus(200)}async handleCoverageResetForConfig(e,t){return this.handleConfigScopedRequest(e,t,async e=>{this.logger.info(`Discarding collected coverage information for config '${e}' as requested via the control API.`);let t=this.storage.getApplicationsWithConfig(e);for(let e of t)this.storage.discardCollectedCoverage(e)})}async handleConfigScopedRequest(e,t,n){let r=e.params.configId;if(typeof r!=`string`||r.trim().length===0)throw Error(`Empty or undefined config ID`);try{await n(r),t.sendStatus(200)}catch(e){t.set(`Content-Type`,`text/plain`),t.send(`Failed to handle request for config '${r}': ${e.message}`),t.sendStatus(500)}}async handleStopCollectorReset(e,t){this.collectorStopCallback?(await this.collectorStopCallback(),this.logger.info(`Shutting down the collector as requested via the control API.`)):this.logger.error(`No collector stop callback was set. Cannot shut down the collector.`),t.status(200).end(),process.exit(0)}setCollectorStopCallback(e){this.collectorStopCallback=e}},De=`@teamscale/coverage-collector`,Oe=`1.0.4`,ke=`Collector for JavaScript code coverage information`,Ae=class e{static buildLogger(e){let n=e.logToFile.trim();m.sync(d.dirname(n));let r=e.logLevel,i=o.createLogger({name:`Collector`,streams:[{level:r,stream:new ce,type:`raw`},{level:r,stream:new le(t.createWriteStream(n)),type:`raw`}]});return e.jsonLog&&i.addStream({level:r,path:`${n}.json`}),i}static async run(){let t=ve(),n=ge(t,{about:ke,version:Oe,name:De});return await e.runWithConfig(t,n)}static async runWithConfig(e,t){let n=this.buildLogger(t);n.info(`Starting collector in working directory "${s.cwd()}".`),n.info(`Will dump coverage to directory "${t.dumpFolder}".`),n.info(`Logging "${t.logLevel}" to "${t.logToFile}".`),t.teamscaleServerUrl&&(await C(t,n)||(n.error(`Could not connect to Teamscale with the given credential. Please check your configuration.`),s.exit(1))),t.dumpFolder&&je(d.resolve(t.dumpFolder),n);let r=ye();r.addArgumentCheck(e=>{if(t.teamscaleServerUrl&&e.teamscaleProject&&!e.teamscalePartition)return`The Teamscale project (parameter teamscaleProject) and coverage partition (parameter teamscalePartition) must be configured for an upload to Teamscale.`});let i=new xe(n,_e(e,r),r,t),a=new Te(t.port,i,n),o=new Ee(t,i,n),c=o.start(),l=a.start(),u=D.startRegularCollectorProcesses(i,n),f=this.startNoMessageTimer(n,a),p=async function(e=!1){n.info(`Stopping the collector.`),l.stop(),f.stop(),u.stop(),await new Promise(e=>{setTimeout(async()=>{await D.dumpCoverage(i,n),e(void 0)},0)}),e||await c.stop(),n.info(`Bye bye.`)};return o.setCollectorStopCallback(()=>p(!0)),{stop:p}}static startNoMessageTimer(e,t){let n=Date.now(),r=setInterval(async()=>{t.getStatistics().totalCoverageMessages===0?e.info(`No coverage received for ${((Date.now()-n)/1e3).toFixed(0)}s.`):clearInterval(r)},1e3*60);return{stop:()=>clearInterval(r)}}};function je(e,n){try{if(m.sync(e),!t.statSync(e).isDirectory())throw Error(`"${e}" exists but is not a directory.`);let n=d.join(e,`.write-test-${Date.now()}`);t.writeFileSync(n,`writable?`),t.unlinkSync(n)}catch(t){n.error(`The configured dump folder "${e}" is not writable or usable: ${t.message}`),s.exit(1)}}(async()=>{let e=await Ae.run(),t=async()=>{await e.stop(),c.exit(c.exitCode)};c.on(`SIGINT`,()=>t()),c.on(`SIGTERM`,()=>t())})().catch(e=>{e instanceof k?console.error(`Failed: ${e.message}`):console.error(`Failed: `,e),c.exit(1)});export{};
package/package.json CHANGED
@@ -1,37 +1,45 @@
1
1
  {
2
2
  "name": "@teamscale/coverage-collector",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.4",
4
4
  "description": "Collector for JavaScript code coverage information",
5
- "main": "dist/src/main.js",
6
- "bin": "dist/src/main.js",
7
- "types": "dist/src/main.d.ts",
5
+ "main": "dist/main.mjs",
6
+ "bin": "dist/main.mjs",
8
7
  "author": "CQSE GmbH",
9
8
  "license": "Apache-2.0",
10
9
  "homepage": "https://docs.teamscale.com/howto/setting-up-profiler-tga/javascript/",
10
+ "keywords": [
11
+ "javascript",
12
+ "coverage",
13
+ "test coverage",
14
+ "coverage profiler",
15
+ "testing",
16
+ "coverage collector",
17
+ "teamscale"
18
+ ],
11
19
  "files": [
12
20
  "dist/**/*"
13
21
  ],
14
22
  "dependencies": {
15
23
  "async": "^3.2.6",
16
- "axios": "^1.7.9",
24
+ "axios": "^1.12.2",
17
25
  "bunyan": "^1.8.15",
18
- "dotenv": "^16.4.7",
19
- "express": "^5.0.1",
20
- "form-data": "^4.0.2",
26
+ "dotenv": "^17.2.3",
27
+ "express": "^5.1.0",
28
+ "form-data": "^4.0.4",
21
29
  "mkdirp": "^3.0.1",
22
30
  "node-cache": "^5.1.2",
23
- "source-map": "^0.7.4",
24
- "tmp": "^0.2.3",
25
- "ws": "^8.18.0",
26
- "@cqse/commons": "1.0.0-beta.6"
31
+ "source-map": "^0.7.6",
32
+ "tmp": "^0.2.5",
33
+ "ws": "^8.18.3",
34
+ "@cqse/commons": "1.0.4"
27
35
  },
28
36
  "publishConfig": {
29
37
  "access": "public"
30
38
  },
31
39
  "scripts": {
32
- "clean": "rimraf dist tsconfig.tsbuildinfo",
33
- "build": "tsc",
34
- "collector": "node dist/src/main.js",
35
- "test": "pnpm build && NODE_OPTIONS='--experimental-vm-modules' jest --coverage --silent=true --detectOpenHandles --forceExit"
40
+ "clean": "rimraf dist",
41
+ "build": "pnpm tsc --noEmit && pnpm tsdown",
42
+ "collector": "node dist/main.mjs",
43
+ "test": "pnpm build && node --import tsx --test --test-concurrency=1 test/**/*.test.ts"
36
44
  }
37
45
  }
package/dist/package.json DELETED
@@ -1,57 +0,0 @@
1
- {
2
- "name": "@teamscale/coverage-collector",
3
- "version": "1.0.0-beta.6",
4
- "description": "Collector for JavaScript code coverage information",
5
- "main": "dist/src/main.js",
6
- "bin": "dist/src/main.js",
7
- "types": "dist/src/main.d.ts",
8
- "author": "CQSE GmbH",
9
- "license": "Apache-2.0",
10
- "homepage": "https://docs.teamscale.com/howto/setting-up-profiler-tga/javascript/",
11
- "scripts": {
12
- "prepublishOnly": "pnpm clean && pnpm build",
13
- "clean": "rimraf dist tsconfig.tsbuildinfo",
14
- "build": "tsc",
15
- "collector": "node dist/src/main.js",
16
- "test": "pnpm build && NODE_OPTIONS='--experimental-vm-modules' jest --coverage --silent=true --detectOpenHandles --forceExit"
17
- },
18
- "files": [
19
- "dist/**/*"
20
- ],
21
- "dependencies": {
22
- "@cqse/commons": "workspace:../cqse-commons",
23
- "async": "^3.2.6",
24
- "axios": "^1.7.9",
25
- "bunyan": "^1.8.15",
26
- "dotenv": "^16.4.7",
27
- "express": "^5.0.1",
28
- "form-data": "^4.0.2",
29
- "mkdirp": "^3.0.1",
30
- "node-cache": "^5.1.2",
31
- "source-map": "^0.7.4",
32
- "tmp": "^0.2.3",
33
- "ws": "^8.18.0"
34
- },
35
- "devDependencies": {
36
- "@babel/core": "^7.26.8",
37
- "@babel/preset-env": "^7.26.8",
38
- "@types/async": "^3.2.24",
39
- "@types/bunyan": "^1.8.11",
40
- "@types/express": "^5.0.0",
41
- "@types/jest": "^29.5.14",
42
- "@types/node": "^22.13.4",
43
- "@types/tmp": "^0.2.6",
44
- "@types/ws": "^8.5.14",
45
- "babel-jest": "^29.7.0",
46
- "esbuild": "^0.25.0",
47
- "jest": "^29.7.0",
48
- "mockttp": "3.15.5",
49
- "rimraf": "^6.0.1",
50
- "ts-jest": "^29.2.5",
51
- "ts-node": "^10.9.2",
52
- "typescript": "^5.7.3"
53
- },
54
- "publishConfig": {
55
- "access": "public"
56
- }
57
- }
@@ -1,24 +0,0 @@
1
- import { CollectorOptions } from '@cqse/commons';
2
- import Logger from 'bunyan';
3
- type ProfilerConfiguration = {
4
- configurationId: string;
5
- configurationOptions?: string;
6
- };
7
- /**
8
- * Utility methods for handling remote profiler configuration. The configuration is retrieved from Teasmcale.
9
- */
10
- export declare class RemoteProfilerConfig {
11
- /**
12
- * Query the profiler configuration with the given ID via the Teamscale API.
13
- */
14
- static queryConfiguration(baseConfig: CollectorOptions, configId: string, logger: Logger): Promise<Map<string, string>>;
15
- /**
16
- * Visible for testing.
17
- */
18
- static parseConfigurationStringIntoMap(response: ProfilerConfiguration): Map<string, string>;
19
- }
20
- /**
21
- * For testing, clear the configuration cache.
22
- */
23
- export declare function clearConfigurationCache(): void;
24
- export {};
@@ -1,76 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.RemoteProfilerConfig = void 0;
7
- exports.clearConfigurationCache = clearConfigurationCache;
8
- const RestApis_1 = require("../utils/RestApis");
9
- const commons_1 = require("@cqse/commons");
10
- const axios_1 = __importDefault(require("axios"));
11
- const node_cache_1 = __importDefault(require("node-cache"));
12
- /**
13
- * We cache the configuration we got from Teamscale for 60s.
14
- */
15
- const CONFIG_CACHE = new node_cache_1.default({ stdTTL: 60, deleteOnExpire: true });
16
- /**
17
- * Utility methods for handling remote profiler configuration. The configuration is retrieved from Teasmcale.
18
- */
19
- class RemoteProfilerConfig {
20
- /**
21
- * Query the profiler configuration with the given ID via the Teamscale API.
22
- */
23
- static async queryConfiguration(baseConfig, configId, logger) {
24
- if (!baseConfig.teamscaleServerUrl || !baseConfig.teamscaleAccessToken || !baseConfig.teamscaleUser) {
25
- throw new Error("Access to Teamscale is not configured. Receiving profiler configurations is not possible. " +
26
- "Please specify configuration arguments for --teamscale-server-url, --teamscale-user, and --teamscale-access-token");
27
- }
28
- let result = CONFIG_CACHE.get(configId);
29
- try {
30
- if (result) {
31
- logger.debug('Using cached configuration with id ' + configId);
32
- return result;
33
- }
34
- logger.debug(`Requesting configuration with ID ${configId} from Teamscale.`);
35
- const response = await (0, RestApis_1.performRequest)(`${(0, RestApis_1.removeTrailingUrlSlash)(baseConfig.teamscaleServerUrl)}/api/${RestApis_1.TEAMSCALE_API_VERSION}/profiler-configurations/${configId}`, undefined, (0, RestApis_1.prepareTeamscaleApiRequestConfig)(baseConfig, {}), axios_1.default.get, logger);
36
- if (response === undefined) {
37
- throw new commons_1.InvalidConfigurationException(`No configuration found with ID ${configId}`);
38
- }
39
- // Fail for an empty configuration. We do not accept an empty configuration
40
- // for a specified configuration ID.
41
- if (!response.configurationOptions || response.configurationOptions.trim().length === 0) {
42
- throw new commons_1.InvalidConfigurationException(`Configuration with ID "${configId}" is empty. Please specify relevant configuration options.`);
43
- }
44
- result = this.parseConfigurationStringIntoMap(response);
45
- CONFIG_CACHE.set(configId, result);
46
- }
47
- catch (error) {
48
- throw new Error(`Failed to retrieve configuration with ID ${configId}: ${error.message}`, { cause: error });
49
- }
50
- return result;
51
- }
52
- /**
53
- * Visible for testing.
54
- */
55
- static parseConfigurationStringIntoMap(response) {
56
- const result = new Map();
57
- (response.configurationOptions ?? "").split(/[\r\n]+/).map(line => line.trim())
58
- .filter(line => line.length > 0)
59
- .filter(line => !line.startsWith('#'))
60
- .forEach(line => {
61
- const split = line.split('=');
62
- if (split.length !== 2) {
63
- throw new commons_1.InvalidConfigurationException(`Invalid configuration line; expecting a valid key=value pair: ${line}`);
64
- }
65
- result.set(split[0].trim(), split[1].trim());
66
- });
67
- return result;
68
- }
69
- }
70
- exports.RemoteProfilerConfig = RemoteProfilerConfig;
71
- /**
72
- * For testing, clear the configuration cache.
73
- */
74
- function clearConfigurationCache() {
75
- CONFIG_CACHE.flushAll();
76
- }
@@ -1,31 +0,0 @@
1
- import { ConfigurationParameters, StaticCollectorOptions } from '@cqse/commons';
2
- import 'dotenv/config';
3
- /**
4
- * Callback interface to be called to stop a process in a controlled fashion.
5
- */
6
- export type Stoppable = {
7
- stop: () => Promise<void>;
8
- };
9
- /**
10
- * The main class of the Teamscale JavaScript Collector.
11
- * Used to start the collector with a given configuration.
12
- */
13
- export declare class App {
14
- /**
15
- * Construct the logger.
16
- */
17
- private static buildLogger;
18
- /**
19
- * Entry point of the Teamscale JavaScript Profiler.
20
- */
21
- static run(): Promise<Stoppable>;
22
- /**
23
- * Run the collector with the given configuration options.
24
- */
25
- static runWithConfig(staticParameters: ConfigurationParameters, config: StaticCollectorOptions): Promise<Stoppable>;
26
- /**
27
- * Starts a timer that shows a message every min that no coverage
28
- * was received until the opposite is the case.
29
- */
30
- private static startNoMessageTimer;
31
- }
@@ -1,202 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- var __importDefault = (this && this.__importDefault) || function (mod) {
36
- return (mod && mod.__esModule) ? mod : { "default": mod };
37
- };
38
- Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.App = void 0;
40
- const CoverageDumper_1 = require("./CoverageDumper");
41
- const StdConsoleLogger_1 = require("../utils/StdConsoleLogger");
42
- const PrettyFileLogger_1 = require("../utils/PrettyFileLogger");
43
- const DataStorage_1 = require("../storage/DataStorage");
44
- const CollectingServer_1 = require("../receiver/CollectingServer");
45
- const ControlServer_1 = require("./ControlServer");
46
- const commons_1 = require("@cqse/commons");
47
- const package_json_1 = require("../../package.json");
48
- const bunyan_1 = __importDefault(require("bunyan"));
49
- require("dotenv/config");
50
- const fs = __importStar(require("fs"));
51
- const mkdirp_1 = require("mkdirp");
52
- const path_1 = __importDefault(require("path"));
53
- const TeamscaleUpload_1 = require("../upload/TeamscaleUpload");
54
- const process = __importStar(require("node:process"));
55
- /**
56
- * The main class of the Teamscale JavaScript Collector.
57
- * Used to start the collector with a given configuration.
58
- */
59
- class App {
60
- /**
61
- * Construct the logger.
62
- */
63
- static buildLogger(config) {
64
- const logfilePath = config.logToFile.trim();
65
- mkdirp_1.mkdirp.sync(path_1.default.dirname(logfilePath));
66
- const logLevel = config.logLevel;
67
- const logger = bunyan_1.default.createLogger({
68
- name: 'Collector',
69
- streams: [
70
- // console output
71
- { level: logLevel, stream: new StdConsoleLogger_1.StdConsoleLogger(), type: 'raw' },
72
- // default log file
73
- { level: logLevel, stream: new PrettyFileLogger_1.PrettyFileLogger(fs.createWriteStream(logfilePath)), type: 'raw' }
74
- ]
75
- });
76
- // If the given flag is set, we also log with a JSON-like format
77
- if (config.jsonLog) {
78
- logger.addStream({ level: logLevel, path: `${logfilePath}.json` });
79
- }
80
- return logger;
81
- }
82
- /**
83
- * Entry point of the Teamscale JavaScript Profiler.
84
- */
85
- static async run() {
86
- const configParameters = (0, commons_1.buildStaticCollectorParameters)();
87
- const appInfos = { about: package_json_1.description, version: package_json_1.version, name: package_json_1.name };
88
- const config = (0, commons_1.processCommandLine)(configParameters, appInfos);
89
- return await App.runWithConfig(configParameters, config);
90
- }
91
- /**
92
- * Run the collector with the given configuration options.
93
- */
94
- static async runWithConfig(staticParameters, config) {
95
- // Build the logger
96
- const logger = this.buildLogger(config);
97
- logger.info(`Starting collector in working directory "${process.cwd()}".`);
98
- logger.info(`Will dump coverage to directory "${config.dumpFolder}".`);
99
- logger.info(`Logging "${config.logLevel}" to "${config.logToFile}".`);
100
- // Check the connection to Teamscale, if needed.
101
- if (config.teamscaleServerUrl) {
102
- const checkResult = await (0, TeamscaleUpload_1.checkTeamscaleCredentials)(config, logger);
103
- if (!checkResult) {
104
- logger.error('Could not connect to Teamscale with the given credential. Please check your configuration.');
105
- process.exit(1);
106
- }
107
- }
108
- // Ensure that the root coverage folder is writable.
109
- if (config.dumpFolder) {
110
- const dumpFolder = path_1.default.resolve(config.dumpFolder);
111
- ensureWritableFolder(dumpFolder, logger);
112
- }
113
- // Prepare the storage and the server
114
- const reconfigurableParameters = (0, commons_1.buildReconfigurableCollectorParameters)();
115
- // Now also add checks for configuration arguments that are to be online, that is,
116
- // when receiving configuration updates from Teamscale or via the control API.
117
- reconfigurableParameters.addArgumentCheck((options) => {
118
- if (config.teamscaleServerUrl) {
119
- // While the connection to Teamscale can be configured, not all
120
- // uploads might be targeted to that but some shall still be stored to the disk.
121
- // In case a Teamscale project is configured, we expect that an upload to Teamscale shall happen.
122
- if (options.teamscaleProject && !options.teamscalePartition) {
123
- return 'The Teamscale project (parameter teamscaleProject) and coverage partition (parameter teamscalePartition) ' +
124
- 'must be configured for an upload to Teamscale.';
125
- }
126
- }
127
- });
128
- const storage = new DataStorage_1.DataStorage(logger, (0, commons_1.parameterUnion)(staticParameters, reconfigurableParameters), reconfigurableParameters, config);
129
- const server = new CollectingServer_1.WebSocketCollectingServer(config.port, storage, logger);
130
- // Enable the remote control API if configured
131
- const controlServer = new ControlServer_1.ControlServer(config, storage, logger);
132
- const controlServerState = controlServer.start();
133
- // Start the server socket.
134
- // ATTENTION: The server is executed asynchronously.
135
- const serverState = server.start();
136
- // Optionally, start a timer that dumps the coverage after N seconds
137
- const dumpTimerState = CoverageDumper_1.CoverageDumper.startRegularCollectorProcesses(storage, logger);
138
- // Start a timer that informs if no coverage was received within the last minute
139
- const statsTimerState = this.startNoMessageTimer(logger, server);
140
- const stop = async function () {
141
- logger.info('Stopping the collector.');
142
- // Final dump before stop. The await/async construct that we use here
143
- // is used to make sure that other events in the event loop are processed
144
- // before the actual dump happens, that is, to retrieve coverage that was
145
- // already sent but has not yet been processed in the collector.
146
- // We need this, for example, in our system tests where everything runs in one NodeJS environment.
147
- await new Promise(resolve => {
148
- setTimeout(async () => {
149
- await CoverageDumper_1.CoverageDumper.dumpCoverage(storage, logger);
150
- resolve(undefined);
151
- }, 0);
152
- });
153
- // Stop all timers and sockets.
154
- dumpTimerState.stop();
155
- statsTimerState.stop();
156
- await controlServerState.stop();
157
- serverState.stop();
158
- logger.info('Bye bye.');
159
- };
160
- return { stop };
161
- }
162
- /**
163
- * Starts a timer that shows a message every min that no coverage
164
- * was received until the opposite is the case.
165
- */
166
- static startNoMessageTimer(logger, server) {
167
- const startTime = Date.now();
168
- const timer = setInterval(async () => {
169
- const stats = server.getStatistics();
170
- if (stats.totalCoverageMessages === 0) {
171
- logger.info(`No coverage received for ${((Date.now() - startTime) / 1000.0).toFixed(0)}s.`);
172
- }
173
- else {
174
- // We can stop running the timer after we have received the first coverage.
175
- clearInterval(timer);
176
- }
177
- }, 1000 * 60);
178
- return {
179
- stop: () => clearInterval(timer)
180
- };
181
- }
182
- }
183
- exports.App = App;
184
- function ensureWritableFolder(dumpFolder, logger) {
185
- try {
186
- // 1. Create the folder when it doesn't exist (recursive = true is a no-op when it does).
187
- mkdirp_1.mkdirp.sync(dumpFolder);
188
- // 2. Verify that we ended up with a directory.
189
- const stat = fs.statSync(dumpFolder);
190
- if (!stat.isDirectory()) {
191
- throw new Error(`"${dumpFolder}" exists but is not a directory.`);
192
- }
193
- // 3. Perform a real write test.
194
- const testFile = path_1.default.join(dumpFolder, `.write-test-${Date.now()}`);
195
- fs.writeFileSync(testFile, 'writable?');
196
- fs.unlinkSync(testFile);
197
- }
198
- catch (err) {
199
- logger.error(`The configured dump folder "${dumpFolder}" is not writable or usable: ${err.message}`);
200
- process.exit(1);
201
- }
202
- }
@@ -1,24 +0,0 @@
1
- import { DataStorage } from '../storage/DataStorage';
2
- import Logger from 'bunyan';
3
- import { CollectorOptions } from '@cqse/commons';
4
- /**
5
- * Provides a REST API for remote configuration of the collector.
6
- */
7
- export declare class ControlServer {
8
- private config;
9
- private storage;
10
- private logger;
11
- constructor(config: CollectorOptions, storage: DataStorage, logger: Logger);
12
- /**
13
- * Start the collector remote config API.
14
- */
15
- start(): {
16
- stop: () => Promise<void>;
17
- };
18
- private handleRefreshConfigs;
19
- private handleDumpPost;
20
- private handleDumpPostForConfig;
21
- private handleGlobalCoverageReset;
22
- private handleCoverageResetForConfig;
23
- private handleConfigScopedRequest;
24
- }