testpulse-jest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/attachmentStore.d.ts +14 -0
- package/dist/attachmentStore.js +78 -0
- package/dist/caseStore.d.ts +15 -0
- package/dist/caseStore.js +68 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +31 -0
- package/dist/describeStack.d.ts +23 -0
- package/dist/describeStack.js +67 -0
- package/dist/httpClient.d.ts +12 -0
- package/dist/httpClient.js +35 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +14 -0
- package/dist/jestState.d.ts +6 -0
- package/dist/jestState.js +25 -0
- package/dist/junitTestCaseProperties.d.ts +14 -0
- package/dist/junitTestCaseProperties.js +26 -0
- package/dist/reporter.d.ts +23 -0
- package/dist/reporter.js +175 -0
- package/dist/scratchDir.d.ts +16 -0
- package/dist/scratchDir.js +73 -0
- package/dist/testpulse.d.ts +21 -0
- package/dist/testpulse.js +45 -0
- package/dist/testpulseAttach.d.ts +11 -0
- package/dist/testpulseAttach.js +65 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Barayo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# testpulse-jest
|
|
2
|
+
|
|
3
|
+
A Jest reporter + [`jest-junit`](https://github.com/jest-community/jest-junit)
|
|
4
|
+
companion for reporting Jest results into
|
|
5
|
+
[TestPulse](https://github.com/Barayo/TestPulse) — matches each test to an
|
|
6
|
+
existing TestPulse case by key and submits the run automatically.
|
|
7
|
+
|
|
8
|
+
Jest has no built-in way for a running test to attach custom metadata to
|
|
9
|
+
its own result (no `record_property()`-equivalent). `testpulse-jest` works
|
|
10
|
+
around that by tagging tests through a small wrapper, injecting the case
|
|
11
|
+
key into `jest-junit`'s own JUnit XML output via its property hook, and
|
|
12
|
+
then submitting that report.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install --save-dev testpulse-jest jest-junit
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configure
|
|
21
|
+
|
|
22
|
+
Three pieces need to be wired into `jest.config.js`: `jest-junit` itself,
|
|
23
|
+
the property-injection hook this package ships (which needs a
|
|
24
|
+
`path.dirname()`/`path.basename()` split — `jest-junit` joins its two
|
|
25
|
+
`testCaseProperties*` options with `path.join()`, which mangles a single
|
|
26
|
+
combined absolute path), and this package's own reporter, **listed after
|
|
27
|
+
`jest-junit`**:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
// jest.config.js
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const propsPath = require.resolve('testpulse-jest/junitTestCaseProperties');
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
reporters: [
|
|
36
|
+
'default',
|
|
37
|
+
['jest-junit', {
|
|
38
|
+
outputName: 'junit.xml',
|
|
39
|
+
// jest-junit's default suiteNameTemplate ("{title}") resolves to the
|
|
40
|
+
// literal string "undefined" for a flat test() with no enclosing
|
|
41
|
+
// describe() -- {filepath} always names the run meaningfully.
|
|
42
|
+
suiteNameTemplate: '{filepath}',
|
|
43
|
+
testCasePropertiesDirectory: path.dirname(propsPath),
|
|
44
|
+
testCasePropertiesFile: path.basename(propsPath),
|
|
45
|
+
}],
|
|
46
|
+
['testpulse-jest', {
|
|
47
|
+
url: 'http://localhost:8080',
|
|
48
|
+
project: 'LOGIN',
|
|
49
|
+
}],
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Tag your tests
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
const { testpulse, testpulseAttach } = require('testpulse-jest');
|
|
58
|
+
|
|
59
|
+
testpulse('LOGIN-42', { platform: 'linux', tags: ['smoke'] })(
|
|
60
|
+
'logs in successfully',
|
|
61
|
+
async () => {
|
|
62
|
+
// ...
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
testpulse('LOGIN-43')('fails with a bad password', async () => {
|
|
67
|
+
const screenshot = await page.screenshot();
|
|
68
|
+
await testpulseAttach(screenshot, { filename: 'failure.png', contentType: 'image/png' });
|
|
69
|
+
// ...
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`testpulse()` wraps `test`/`it` — the test's own title is never modified,
|
|
74
|
+
so nothing shows up differently in console output, CI logs, or the JUnit
|
|
75
|
+
report's own `<testcase name>`. `testpulseAttach()` only accepts
|
|
76
|
+
`image/png`, `image/jpeg`, and `image/webp`, and only works inside a test
|
|
77
|
+
already tagged via `testpulse()` — calling it from an untagged test logs a
|
|
78
|
+
warning and drops the attachment.
|
|
79
|
+
|
|
80
|
+
## Configuration reference
|
|
81
|
+
|
|
82
|
+
Each setting resolves from an environment variable first, falling back to
|
|
83
|
+
the matching reporter option in `jest.config.js`:
|
|
84
|
+
|
|
85
|
+
| Setting | Env var | Reporter option |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| API base URL | `TESTPULSE_URL` | `url` |
|
|
88
|
+
| API token | `TESTPULSE_TOKEN` | `token` |
|
|
89
|
+
| Project key | `TESTPULSE_PROJECT` | `project` |
|
|
90
|
+
| Fail build on unmatched cases | `TESTPULSE_FAIL_ON_UNMATCHED` | `failOnUnmatched` |
|
|
91
|
+
| Dry run (preview only) | `TESTPULSE_DRY_RUN` | `dryRun` |
|
|
92
|
+
| jest-junit output path | — | `junitFile` (default `./junit.xml`) |
|
|
93
|
+
| Scratch directory | — | `scratchDir` (default `.testpulse/`) |
|
|
94
|
+
| Keep scratch dir after run | — | `keepScratchDir` (default `false`) |
|
|
95
|
+
|
|
96
|
+
Put the token in `TESTPULSE_TOKEN` (a CI secret), not in a committed
|
|
97
|
+
`jest.config.js` — the reporter option exists but env var wins, so a
|
|
98
|
+
committed placeholder never accidentally shadows the real secret.
|
|
99
|
+
|
|
100
|
+
`--testpulse` isn't a real CLI flag family here — Jest owns the CLI, so
|
|
101
|
+
all of this is configured via `jest.config.js`'s `reporters` entry and env
|
|
102
|
+
vars, not command-line flags.
|
|
103
|
+
|
|
104
|
+
## Build outcome
|
|
105
|
+
|
|
106
|
+
| API response | Behavior |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `201` all matched | build succeeds; summary logged |
|
|
109
|
+
| `207` some unmatched | build succeeds by default (unmatched case keys + `failOnUnmatched` pointer logged); fails when `failOnUnmatched` is set |
|
|
110
|
+
| network/auth/4xx/5xx error | always fails the build, unconditionally |
|
|
111
|
+
|
|
112
|
+
`dryRun: true` (or `TESTPULSE_DRY_RUN=1`) previews matches via a read-only
|
|
113
|
+
`GET /cases` request and never submits anything — never affects the exit
|
|
114
|
+
code.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const SUPPORTED_CONTENT_TYPES: readonly ["image/png", "image/jpeg", "image/webp"];
|
|
2
|
+
export type SupportedContentType = (typeof SUPPORTED_CONTENT_TYPES)[number];
|
|
3
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
4
|
+
export interface AttachmentMetadata {
|
|
5
|
+
fullName: string;
|
|
6
|
+
filename: string;
|
|
7
|
+
contentType: SupportedContentType;
|
|
8
|
+
}
|
|
9
|
+
export interface WrittenAttachment extends AttachmentMetadata {
|
|
10
|
+
dataPath: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function isSupportedContentType(contentType: string): contentType is SupportedContentType;
|
|
13
|
+
export declare function writeAttachment(scratchDir: string, fullName: string, data: Buffer, filename: string, contentType: string): void;
|
|
14
|
+
export declare function readAllAttachments(scratchDir: string): WrittenAttachment[];
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MAX_ATTACHMENT_BYTES = exports.SUPPORTED_CONTENT_TYPES = void 0;
|
|
37
|
+
exports.isSupportedContentType = isSupportedContentType;
|
|
38
|
+
exports.writeAttachment = writeAttachment;
|
|
39
|
+
exports.readAllAttachments = readAllAttachments;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const scratchDir_1 = require("./scratchDir");
|
|
43
|
+
exports.SUPPORTED_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
|
44
|
+
// A screenshot this large is almost certainly a mistake (wrong file, an
|
|
45
|
+
// uncompressed capture) -- failing fast here with a clear message beats
|
|
46
|
+
// finding out via a slow timeout or a 413 from the server after the whole
|
|
47
|
+
// base64 payload has already been built and sent.
|
|
48
|
+
exports.MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
49
|
+
function isSupportedContentType(contentType) {
|
|
50
|
+
return exports.SUPPORTED_CONTENT_TYPES.includes(contentType);
|
|
51
|
+
}
|
|
52
|
+
function writeAttachment(scratchDir, fullName, data, filename, contentType) {
|
|
53
|
+
if (!isSupportedContentType(contentType)) {
|
|
54
|
+
throw new Error(`testpulse-jest: testpulseAttach() only supports ${exports.SUPPORTED_CONTENT_TYPES.join(', ')}, got: ${contentType}`);
|
|
55
|
+
}
|
|
56
|
+
if (data.length > exports.MAX_ATTACHMENT_BYTES) {
|
|
57
|
+
throw new Error(`testpulse-jest: testpulseAttach() attachment is ${data.length} bytes, exceeding the ${exports.MAX_ATTACHMENT_BYTES}-byte limit.`);
|
|
58
|
+
}
|
|
59
|
+
const dir = (0, scratchDir_1.attachmentsDir)(scratchDir);
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
61
|
+
const hashed = (0, scratchDir_1.hashFullName)(fullName);
|
|
62
|
+
const metadata = { fullName, filename, contentType };
|
|
63
|
+
fs.writeFileSync(path.join(dir, `${hashed}.json`), JSON.stringify(metadata), 'utf8');
|
|
64
|
+
fs.writeFileSync(path.join(dir, `${hashed}.data`), data);
|
|
65
|
+
}
|
|
66
|
+
function readAllAttachments(scratchDir) {
|
|
67
|
+
const dir = (0, scratchDir_1.attachmentsDir)(scratchDir);
|
|
68
|
+
if (!fs.existsSync(dir))
|
|
69
|
+
return [];
|
|
70
|
+
return fs
|
|
71
|
+
.readdirSync(dir)
|
|
72
|
+
.filter((f) => f.endsWith('.json'))
|
|
73
|
+
.map((f) => {
|
|
74
|
+
const metadata = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
75
|
+
const dataPath = path.join(dir, f.replace(/\.json$/, '.data'));
|
|
76
|
+
return { ...metadata, dataPath };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface CaseMetadata {
|
|
2
|
+
fullName: string;
|
|
3
|
+
caseKey: string;
|
|
4
|
+
platform?: string;
|
|
5
|
+
version?: string;
|
|
6
|
+
tags?: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface CaseOptions {
|
|
9
|
+
platform?: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
tags?: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare function writeCaseMetadata(scratchDir: string, fullName: string, caseKey: string, opts?: CaseOptions): void;
|
|
14
|
+
export declare function readCaseMetadata(scratchDir: string, fullName: string): CaseMetadata | undefined;
|
|
15
|
+
export declare function readAllCaseMetadata(scratchDir: string): CaseMetadata[];
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.writeCaseMetadata = writeCaseMetadata;
|
|
37
|
+
exports.readCaseMetadata = readCaseMetadata;
|
|
38
|
+
exports.readAllCaseMetadata = readAllCaseMetadata;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const scratchDir_1 = require("./scratchDir");
|
|
42
|
+
function writeCaseMetadata(scratchDir, fullName, caseKey, opts = {}) {
|
|
43
|
+
const metadata = {
|
|
44
|
+
fullName,
|
|
45
|
+
caseKey,
|
|
46
|
+
...(opts.platform ? { platform: opts.platform } : {}),
|
|
47
|
+
...(opts.version ? { version: opts.version } : {}),
|
|
48
|
+
...(opts.tags && opts.tags.length > 0 ? { tags: opts.tags } : {}),
|
|
49
|
+
};
|
|
50
|
+
const dir = (0, scratchDir_1.casesDir)(scratchDir);
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(path.join(dir, `${(0, scratchDir_1.hashFullName)(fullName)}.json`), JSON.stringify(metadata), 'utf8');
|
|
53
|
+
}
|
|
54
|
+
function readCaseMetadata(scratchDir, fullName) {
|
|
55
|
+
const file = path.join((0, scratchDir_1.casesDir)(scratchDir), `${(0, scratchDir_1.hashFullName)(fullName)}.json`);
|
|
56
|
+
if (!fs.existsSync(file))
|
|
57
|
+
return undefined;
|
|
58
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
function readAllCaseMetadata(scratchDir) {
|
|
61
|
+
const dir = (0, scratchDir_1.casesDir)(scratchDir);
|
|
62
|
+
if (!fs.existsSync(dir))
|
|
63
|
+
return [];
|
|
64
|
+
return fs
|
|
65
|
+
.readdirSync(dir)
|
|
66
|
+
.filter((f) => f.endsWith('.json'))
|
|
67
|
+
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
|
|
68
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const DEFAULT_JUNIT_FILE = "./junit.xml";
|
|
2
|
+
export interface ReporterOptions {
|
|
3
|
+
url?: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
project?: string;
|
|
6
|
+
failOnUnmatched?: boolean;
|
|
7
|
+
dryRun?: boolean;
|
|
8
|
+
junitFile?: string;
|
|
9
|
+
scratchDir?: string;
|
|
10
|
+
keepScratchDir?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ResolvedConfig {
|
|
13
|
+
url?: string;
|
|
14
|
+
token?: string;
|
|
15
|
+
project?: string;
|
|
16
|
+
failOnUnmatched: boolean;
|
|
17
|
+
dryRun: boolean;
|
|
18
|
+
junitFile: string;
|
|
19
|
+
scratchDir: string;
|
|
20
|
+
keepScratchDir: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Env var always wins over the reporter's own `jest.config.js` options -- see design.md. */
|
|
23
|
+
export declare function resolveConfig(options?: ReporterOptions): ResolvedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_JUNIT_FILE = void 0;
|
|
4
|
+
exports.resolveConfig = resolveConfig;
|
|
5
|
+
const scratchDir_1 = require("./scratchDir");
|
|
6
|
+
exports.DEFAULT_JUNIT_FILE = './junit.xml';
|
|
7
|
+
function resolveString(envVar, optionValue) {
|
|
8
|
+
const envValue = process.env[envVar];
|
|
9
|
+
if (envValue !== undefined && envValue !== '')
|
|
10
|
+
return envValue;
|
|
11
|
+
return optionValue;
|
|
12
|
+
}
|
|
13
|
+
function resolveBoolean(envVar, optionValue) {
|
|
14
|
+
const envValue = process.env[envVar];
|
|
15
|
+
if (envValue !== undefined && envValue !== '')
|
|
16
|
+
return envValue === 'true' || envValue === '1';
|
|
17
|
+
return optionValue ?? false;
|
|
18
|
+
}
|
|
19
|
+
/** Env var always wins over the reporter's own `jest.config.js` options -- see design.md. */
|
|
20
|
+
function resolveConfig(options = {}) {
|
|
21
|
+
return {
|
|
22
|
+
url: resolveString('TESTPULSE_URL', options.url),
|
|
23
|
+
token: resolveString('TESTPULSE_TOKEN', options.token),
|
|
24
|
+
project: resolveString('TESTPULSE_PROJECT', options.project),
|
|
25
|
+
failOnUnmatched: resolveBoolean('TESTPULSE_FAIL_ON_UNMATCHED', options.failOnUnmatched),
|
|
26
|
+
dryRun: resolveBoolean('TESTPULSE_DRY_RUN', options.dryRun),
|
|
27
|
+
junitFile: options.junitFile ?? exports.DEFAULT_JUNIT_FILE,
|
|
28
|
+
scratchDir: options.scratchDir ?? scratchDir_1.DEFAULT_SCRATCH_DIR,
|
|
29
|
+
keepScratchDir: options.keepScratchDir ?? false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks the currently-executing `describe()` nesting by patching the
|
|
3
|
+
* global `describe` (and `.only`/`.skip`) the first time this module is
|
|
4
|
+
* used. `describe` blocks run synchronously during Jest's collection
|
|
5
|
+
* phase (before any test body executes), so this lets testpulse() know
|
|
6
|
+
* a test's ancestorTitles at *registration* time -- unlike
|
|
7
|
+
* `expect.getState().currentTestName`, which is only available once a
|
|
8
|
+
* test body actually starts running.
|
|
9
|
+
*
|
|
10
|
+
* Known limitation: `describe.each` is not patched -- jest-circus's
|
|
11
|
+
* `.each` implementation calls its own internal `describe` reference
|
|
12
|
+
* rather than looking up `globalThis.describe` per call, so blocks
|
|
13
|
+
* created via `.each` won't be reflected in the tracked stack. A test
|
|
14
|
+
* tagged inside a `describe.each` block still gets correct metadata once
|
|
15
|
+
* it actually runs (the execution-time write in testpulse.ts recomputes
|
|
16
|
+
* the real fullName from `expect.getState()` and overwrites the
|
|
17
|
+
* registration-time sidecar), so this only affects the "still linked
|
|
18
|
+
* even if skipped/filtered" guarantee for that specific nesting case.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getDescribeStack(): string[];
|
|
21
|
+
export declare function ensureDescribeTracked(): void;
|
|
22
|
+
/** Test-only: reset patch state and stack between test cases. */
|
|
23
|
+
export declare function __resetDescribeStackForTests(): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tracks the currently-executing `describe()` nesting by patching the
|
|
4
|
+
* global `describe` (and `.only`/`.skip`) the first time this module is
|
|
5
|
+
* used. `describe` blocks run synchronously during Jest's collection
|
|
6
|
+
* phase (before any test body executes), so this lets testpulse() know
|
|
7
|
+
* a test's ancestorTitles at *registration* time -- unlike
|
|
8
|
+
* `expect.getState().currentTestName`, which is only available once a
|
|
9
|
+
* test body actually starts running.
|
|
10
|
+
*
|
|
11
|
+
* Known limitation: `describe.each` is not patched -- jest-circus's
|
|
12
|
+
* `.each` implementation calls its own internal `describe` reference
|
|
13
|
+
* rather than looking up `globalThis.describe` per call, so blocks
|
|
14
|
+
* created via `.each` won't be reflected in the tracked stack. A test
|
|
15
|
+
* tagged inside a `describe.each` block still gets correct metadata once
|
|
16
|
+
* it actually runs (the execution-time write in testpulse.ts recomputes
|
|
17
|
+
* the real fullName from `expect.getState()` and overwrites the
|
|
18
|
+
* registration-time sidecar), so this only affects the "still linked
|
|
19
|
+
* even if skipped/filtered" guarantee for that specific nesting case.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.getDescribeStack = getDescribeStack;
|
|
23
|
+
exports.ensureDescribeTracked = ensureDescribeTracked;
|
|
24
|
+
exports.__resetDescribeStackForTests = __resetDescribeStackForTests;
|
|
25
|
+
let patched = false;
|
|
26
|
+
const stack = [];
|
|
27
|
+
function getDescribeStack() {
|
|
28
|
+
return [...stack];
|
|
29
|
+
}
|
|
30
|
+
function wrapBlock(original, name, fn, ...rest) {
|
|
31
|
+
return original(name, (...args) => {
|
|
32
|
+
stack.push(name);
|
|
33
|
+
try {
|
|
34
|
+
return fn(...args);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
stack.pop();
|
|
38
|
+
}
|
|
39
|
+
}, ...rest);
|
|
40
|
+
}
|
|
41
|
+
function ensureDescribeTracked() {
|
|
42
|
+
if (patched)
|
|
43
|
+
return;
|
|
44
|
+
const g = globalThis;
|
|
45
|
+
const original = g.describe;
|
|
46
|
+
if (typeof original !== 'function')
|
|
47
|
+
return;
|
|
48
|
+
patched = true;
|
|
49
|
+
const tracked = ((name, fn, ...rest) => wrapBlock(original, name, fn, ...rest));
|
|
50
|
+
// Copy through any other static properties (e.g. `.each`) unpatched first
|
|
51
|
+
// -- best-effort tracking is better than throwing away functionality --
|
|
52
|
+
// then override `.only`/`.skip` with tracked versions so they aren't
|
|
53
|
+
// clobbered by the copy.
|
|
54
|
+
Object.assign(tracked, original);
|
|
55
|
+
if (typeof original.only === 'function') {
|
|
56
|
+
tracked.only = (name, fn, ...rest) => wrapBlock(original.only, name, fn, ...rest);
|
|
57
|
+
}
|
|
58
|
+
if (typeof original.skip === 'function') {
|
|
59
|
+
tracked.skip = (name, fn, ...rest) => wrapBlock(original.skip, name, fn, ...rest);
|
|
60
|
+
}
|
|
61
|
+
g.describe = tracked;
|
|
62
|
+
}
|
|
63
|
+
/** Test-only: reset patch state and stack between test cases. */
|
|
64
|
+
function __resetDescribeStackForTests() {
|
|
65
|
+
patched = false;
|
|
66
|
+
stack.length = 0;
|
|
67
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface HttpResult {
|
|
2
|
+
status: number;
|
|
3
|
+
body: unknown;
|
|
4
|
+
}
|
|
5
|
+
export interface ImportAttachment {
|
|
6
|
+
caseKey: string;
|
|
7
|
+
filename: string;
|
|
8
|
+
contentType: string;
|
|
9
|
+
data: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function postImport(url: string, project: string, token: string | undefined, report: string, attachments: ImportAttachment[]): Promise<HttpResult>;
|
|
12
|
+
export declare function getCases(url: string, project: string, token: string | undefined): Promise<HttpResult>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.postImport = postImport;
|
|
4
|
+
exports.getCases = getCases;
|
|
5
|
+
function joinUrl(base, pathname) {
|
|
6
|
+
return `${base.replace(/\/$/, '')}${pathname}`;
|
|
7
|
+
}
|
|
8
|
+
function authHeaders(token) {
|
|
9
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
10
|
+
}
|
|
11
|
+
async function parseBody(res) {
|
|
12
|
+
const text = await res.text();
|
|
13
|
+
if (!text)
|
|
14
|
+
return undefined;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function postImport(url, project, token, report, attachments) {
|
|
23
|
+
const res = await fetch(joinUrl(url, `/api/v1/projects/${encodeURIComponent(project)}/imports`), {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
|
|
26
|
+
body: JSON.stringify({ format: 'junit-xml', report, attachments }),
|
|
27
|
+
});
|
|
28
|
+
return { status: res.status, body: await parseBody(res) };
|
|
29
|
+
}
|
|
30
|
+
async function getCases(url, project, token) {
|
|
31
|
+
const res = await fetch(joinUrl(url, `/api/v1/projects/${encodeURIComponent(project)}/cases`), {
|
|
32
|
+
headers: authHeaders(token),
|
|
33
|
+
});
|
|
34
|
+
return { status: res.status, body: await parseBody(res) };
|
|
35
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { testpulse } from './testpulse';
|
|
2
|
+
export type { TestpulseOptions } from './testpulse';
|
|
3
|
+
export { testpulseAttach } from './testpulseAttach';
|
|
4
|
+
export type { TestpulseAttachOptions } from './testpulseAttach';
|
|
5
|
+
export { setScratchDir } from './scratchDir';
|
|
6
|
+
export { default } from './reporter';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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.default = exports.setScratchDir = exports.testpulseAttach = exports.testpulse = void 0;
|
|
7
|
+
var testpulse_1 = require("./testpulse");
|
|
8
|
+
Object.defineProperty(exports, "testpulse", { enumerable: true, get: function () { return testpulse_1.testpulse; } });
|
|
9
|
+
var testpulseAttach_1 = require("./testpulseAttach");
|
|
10
|
+
Object.defineProperty(exports, "testpulseAttach", { enumerable: true, get: function () { return testpulseAttach_1.testpulseAttach; } });
|
|
11
|
+
var scratchDir_1 = require("./scratchDir");
|
|
12
|
+
Object.defineProperty(exports, "setScratchDir", { enumerable: true, get: function () { return scratchDir_1.setScratchDir; } });
|
|
13
|
+
var reporter_1 = require("./reporter");
|
|
14
|
+
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(reporter_1).default; } });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type TestFn = (...args: unknown[]) => unknown;
|
|
2
|
+
export type TestRegistrar = (name: string, fn: TestFn, timeout?: number) => void;
|
|
3
|
+
export declare function getGlobalTestRegistrar(): TestRegistrar;
|
|
4
|
+
/** Reconstructs Jest's own fullName from an AssertionResult's ancestorTitles/title -- the same value `currentTestName` holds while the test is running. */
|
|
5
|
+
export declare function reconstructFullName(ancestorTitles: string[], title: string): string;
|
|
6
|
+
export declare function getCurrentTestName(): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getGlobalTestRegistrar = getGlobalTestRegistrar;
|
|
4
|
+
exports.reconstructFullName = reconstructFullName;
|
|
5
|
+
exports.getCurrentTestName = getCurrentTestName;
|
|
6
|
+
function getGlobalTestRegistrar() {
|
|
7
|
+
const g = globalThis;
|
|
8
|
+
const registrar = (g.test ?? g.it);
|
|
9
|
+
if (typeof registrar !== 'function') {
|
|
10
|
+
throw new Error('testpulse-jest: no global test()/it() found -- testpulse() must be called from within a Jest test file');
|
|
11
|
+
}
|
|
12
|
+
return registrar;
|
|
13
|
+
}
|
|
14
|
+
/** Reconstructs Jest's own fullName from an AssertionResult's ancestorTitles/title -- the same value `currentTestName` holds while the test is running. */
|
|
15
|
+
function reconstructFullName(ancestorTitles, title) {
|
|
16
|
+
return [...ancestorTitles, title].join(' ').trim();
|
|
17
|
+
}
|
|
18
|
+
function getCurrentTestName() {
|
|
19
|
+
const g = globalThis;
|
|
20
|
+
const name = g.expect?.getState?.()?.currentTestName;
|
|
21
|
+
if (typeof name !== 'string') {
|
|
22
|
+
throw new Error('testpulse-jest: unable to resolve the current test name via expect.getState().currentTestName -- is this running inside a Jest test?');
|
|
23
|
+
}
|
|
24
|
+
return name;
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface JestJunitTestCase {
|
|
2
|
+
title: string;
|
|
3
|
+
ancestorTitles: string[];
|
|
4
|
+
}
|
|
5
|
+
type JunitProperties = Record<string, string>;
|
|
6
|
+
/**
|
|
7
|
+
* jest-junit's `testCasePropertiesFile` hook. Called synchronously, once
|
|
8
|
+
* per testcase, while jest-junit builds its <properties> block -- reads
|
|
9
|
+
* the scratch dir's case-key sidecars (already flushed to disk by the
|
|
10
|
+
* time jest-junit's own onRunComplete runs) rather than anything on `tc`
|
|
11
|
+
* itself.
|
|
12
|
+
*/
|
|
13
|
+
declare function getTestCaseProperties(tc: JestJunitTestCase): JunitProperties;
|
|
14
|
+
export = getTestCaseProperties;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const scratchDir_1 = require("./scratchDir");
|
|
3
|
+
const caseStore_1 = require("./caseStore");
|
|
4
|
+
const jestState_1 = require("./jestState");
|
|
5
|
+
/**
|
|
6
|
+
* jest-junit's `testCasePropertiesFile` hook. Called synchronously, once
|
|
7
|
+
* per testcase, while jest-junit builds its <properties> block -- reads
|
|
8
|
+
* the scratch dir's case-key sidecars (already flushed to disk by the
|
|
9
|
+
* time jest-junit's own onRunComplete runs) rather than anything on `tc`
|
|
10
|
+
* itself.
|
|
11
|
+
*/
|
|
12
|
+
function getTestCaseProperties(tc) {
|
|
13
|
+
const fullName = (0, jestState_1.reconstructFullName)(tc.ancestorTitles ?? [], tc.title);
|
|
14
|
+
const metadata = (0, caseStore_1.readCaseMetadata)((0, scratchDir_1.getScratchDir)(), fullName);
|
|
15
|
+
if (!metadata)
|
|
16
|
+
return {};
|
|
17
|
+
const properties = { testpulse_case_key: metadata.caseKey };
|
|
18
|
+
if (metadata.platform)
|
|
19
|
+
properties.testpulse_platform = metadata.platform;
|
|
20
|
+
if (metadata.version)
|
|
21
|
+
properties.testpulse_version = metadata.version;
|
|
22
|
+
if (metadata.tags && metadata.tags.length > 0)
|
|
23
|
+
properties.testpulse_tags = metadata.tags.join(',');
|
|
24
|
+
return properties;
|
|
25
|
+
}
|
|
26
|
+
module.exports = getTestCaseProperties;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ReporterOptions } from './config';
|
|
2
|
+
export default class TestPulseReporter {
|
|
3
|
+
private readonly config;
|
|
4
|
+
constructor(_globalConfig: unknown, options?: ReporterOptions);
|
|
5
|
+
/**
|
|
6
|
+
* Runs exactly once, in the main process, before any worker starts
|
|
7
|
+
* executing tests -- the correct place to clear stale scratch-dir
|
|
8
|
+
* contents from a prior (possibly crashed) run. `testpulse()`/
|
|
9
|
+
* `testpulseAttach()` run inside per-test-file worker processes and
|
|
10
|
+
* cannot coordinate a "first call wins" check with each other; see
|
|
11
|
+
* design.md for why that approach was rejected.
|
|
12
|
+
*/
|
|
13
|
+
onRunStart(): void;
|
|
14
|
+
onRunComplete(_contexts?: unknown, results?: {
|
|
15
|
+
startTime?: number;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
private readJunitReport;
|
|
18
|
+
private buildAttachments;
|
|
19
|
+
/** Returns whether the submission itself succeeded (a run was created), independent of failOnUnmatched's effect on exit code. */
|
|
20
|
+
private handleSubmissionResult;
|
|
21
|
+
private runDryRun;
|
|
22
|
+
private cleanup;
|
|
23
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
const fs = __importStar(require("fs"));
|
|
37
|
+
const scratchDir_1 = require("./scratchDir");
|
|
38
|
+
const caseStore_1 = require("./caseStore");
|
|
39
|
+
const attachmentStore_1 = require("./attachmentStore");
|
|
40
|
+
const config_1 = require("./config");
|
|
41
|
+
const httpClient_1 = require("./httpClient");
|
|
42
|
+
class TestPulseReporter {
|
|
43
|
+
constructor(_globalConfig, options = {}) {
|
|
44
|
+
this.config = (0, config_1.resolveConfig)(options);
|
|
45
|
+
(0, scratchDir_1.setScratchDir)(this.config.scratchDir);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Runs exactly once, in the main process, before any worker starts
|
|
49
|
+
* executing tests -- the correct place to clear stale scratch-dir
|
|
50
|
+
* contents from a prior (possibly crashed) run. `testpulse()`/
|
|
51
|
+
* `testpulseAttach()` run inside per-test-file worker processes and
|
|
52
|
+
* cannot coordinate a "first call wins" check with each other; see
|
|
53
|
+
* design.md for why that approach was rejected.
|
|
54
|
+
*/
|
|
55
|
+
onRunStart() {
|
|
56
|
+
fs.rmSync(this.config.scratchDir, { recursive: true, force: true });
|
|
57
|
+
fs.mkdirSync((0, scratchDir_1.casesDir)(this.config.scratchDir), { recursive: true });
|
|
58
|
+
fs.mkdirSync((0, scratchDir_1.attachmentsDir)(this.config.scratchDir), { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
async onRunComplete(_contexts, results) {
|
|
61
|
+
const config = this.config;
|
|
62
|
+
if (config.dryRun) {
|
|
63
|
+
await this.runDryRun(config);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!config.url || !config.project) {
|
|
67
|
+
throw new Error('testpulse-jest: no url/project configured (TESTPULSE_URL/TESTPULSE_PROJECT env vars, or `url`/`project` reporter options in jest.config.js).');
|
|
68
|
+
}
|
|
69
|
+
const junitReport = this.readJunitReport(config.junitFile, results?.startTime);
|
|
70
|
+
const attachments = this.buildAttachments(config.scratchDir);
|
|
71
|
+
let status;
|
|
72
|
+
let body;
|
|
73
|
+
try {
|
|
74
|
+
({ status, body } = await (0, httpClient_1.postImport)(config.url, config.project, config.token, junitReport, attachments));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
// eslint-disable-next-line no-console
|
|
78
|
+
console.error(`testpulse-jest: submission request failed: ${err.message}`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const succeeded = this.handleSubmissionResult(status, body, config);
|
|
83
|
+
// Only clean up on a successful submission (201/207) -- a failed one
|
|
84
|
+
// leaves the scratch dir in place (subject to keepScratchDir's normal
|
|
85
|
+
// meaning) so its contents are actually inspectable when debugging the
|
|
86
|
+
// one situation you'd most want them: a failure.
|
|
87
|
+
if (succeeded)
|
|
88
|
+
this.cleanup(config);
|
|
89
|
+
}
|
|
90
|
+
readJunitReport(junitFile, runStartTime) {
|
|
91
|
+
if (!fs.existsSync(junitFile)) {
|
|
92
|
+
throw new Error(`testpulse-jest: no JUnit report found at "${junitFile}". Check that "testpulse-jest" is listed AFTER ` +
|
|
93
|
+
'"jest-junit" in your jest.config.js "reporters" array, and that jest-junit is configured to write to this path.');
|
|
94
|
+
}
|
|
95
|
+
// A reporters-ordering mistake ("testpulse-jest" before "jest-junit")
|
|
96
|
+
// combined with a pre-existing junit.xml from a prior run (a common,
|
|
97
|
+
// ordinary situation -- local dev re-running jest in the same
|
|
98
|
+
// directory, a reused CI workspace) would otherwise mean this reporter
|
|
99
|
+
// reads and silently submits the OLD report, with no error at all,
|
|
100
|
+
// even though the current run's real results are different. Comparing
|
|
101
|
+
// the file's mtime against this run's own startTime catches that.
|
|
102
|
+
if (typeof runStartTime === 'number') {
|
|
103
|
+
const mtimeMs = fs.statSync(junitFile).mtimeMs;
|
|
104
|
+
if (mtimeMs < runStartTime) {
|
|
105
|
+
throw new Error(`testpulse-jest: the JUnit report at "${junitFile}" is older than this test run -- it wasn't ` +
|
|
106
|
+
'refreshed by jest-junit before this reporter read it. Check that "testpulse-jest" is listed AFTER ' +
|
|
107
|
+
'"jest-junit" in your jest.config.js "reporters" array. Refusing to submit a stale report.');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return fs.readFileSync(junitFile, 'utf8');
|
|
111
|
+
}
|
|
112
|
+
buildAttachments(scratchDir) {
|
|
113
|
+
const caseKeyByFullName = new Map((0, caseStore_1.readAllCaseMetadata)(scratchDir).map((c) => [c.fullName, c.caseKey]));
|
|
114
|
+
const attachments = [];
|
|
115
|
+
for (const attachment of (0, attachmentStore_1.readAllAttachments)(scratchDir)) {
|
|
116
|
+
const caseKey = caseKeyByFullName.get(attachment.fullName);
|
|
117
|
+
if (!caseKey)
|
|
118
|
+
continue;
|
|
119
|
+
attachments.push({
|
|
120
|
+
caseKey,
|
|
121
|
+
filename: attachment.filename,
|
|
122
|
+
contentType: attachment.contentType,
|
|
123
|
+
data: fs.readFileSync(attachment.dataPath).toString('base64'),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return attachments;
|
|
127
|
+
}
|
|
128
|
+
/** Returns whether the submission itself succeeded (a run was created), independent of failOnUnmatched's effect on exit code. */
|
|
129
|
+
handleSubmissionResult(status, body, config) {
|
|
130
|
+
if (status === 201) {
|
|
131
|
+
const run = body;
|
|
132
|
+
// eslint-disable-next-line no-console
|
|
133
|
+
console.log(`testpulse-jest: submitted successfully (run ${run?.id ?? '?'})`);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (status === 207) {
|
|
137
|
+
const info = body;
|
|
138
|
+
const unmatchedKeys = (info?.unmatched ?? []).map((u) => u.caseKey);
|
|
139
|
+
// eslint-disable-next-line no-console
|
|
140
|
+
console.warn(`testpulse-jest: ${unmatchedKeys.length} case(s) unmatched: ${unmatchedKeys.join(', ')}. ` +
|
|
141
|
+
'Set failOnUnmatched (or TESTPULSE_FAIL_ON_UNMATCHED) to make this a hard failure.');
|
|
142
|
+
if (config.failOnUnmatched)
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
// eslint-disable-next-line no-console
|
|
147
|
+
console.error(`testpulse-jest: submission failed (status ${status}): ${JSON.stringify(body)}`);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
async runDryRun(config) {
|
|
152
|
+
if (!config.url || !config.project) {
|
|
153
|
+
throw new Error('testpulse-jest: dryRun requires url/project to be configured (TESTPULSE_URL/TESTPULSE_PROJECT env vars, or `url`/`project` reporter options).');
|
|
154
|
+
}
|
|
155
|
+
const cases = (0, caseStore_1.readAllCaseMetadata)(config.scratchDir);
|
|
156
|
+
const { status, body } = await (0, httpClient_1.getCases)(config.url, config.project, config.token);
|
|
157
|
+
if (status >= 300) {
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.error(`testpulse-jest: dry run failed to fetch cases (status ${status})`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const existingKeys = new Set((body ?? []).map((c) => c.key));
|
|
163
|
+
const unmatched = cases.filter((c) => !existingKeys.has(c.caseKey));
|
|
164
|
+
const matchedCount = cases.length - unmatched.length;
|
|
165
|
+
// eslint-disable-next-line no-console
|
|
166
|
+
console.log(`testpulse-jest: dry run -- ${matchedCount} would match, ${unmatched.length} would not` +
|
|
167
|
+
(unmatched.length > 0 ? `: ${unmatched.map((c) => c.caseKey).join(', ')}` : ''));
|
|
168
|
+
}
|
|
169
|
+
cleanup(config) {
|
|
170
|
+
if (config.keepScratchDir)
|
|
171
|
+
return;
|
|
172
|
+
fs.rmSync(config.scratchDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
exports.default = TestPulseReporter;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const DEFAULT_SCRATCH_DIR = ".testpulse";
|
|
2
|
+
/** Overrides the scratch directory path. Exposed for the reporter's config resolution. */
|
|
3
|
+
export declare function setScratchDir(dir: string): void;
|
|
4
|
+
export declare function getScratchDir(): string;
|
|
5
|
+
export declare function casesDir(base?: string): string;
|
|
6
|
+
export declare function attachmentsDir(base?: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Sidecar filenames are a hash of the fullName, never the fullName itself
|
|
9
|
+
* or any derivative of it used as a path component -- Jest test titles are
|
|
10
|
+
* fully user-controlled strings with no character restrictions, so a name
|
|
11
|
+
* containing `/` or `..` must not be able to influence where the sidecar
|
|
12
|
+
* is written.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hashFullName(fullName: string): string;
|
|
15
|
+
/** Reset the module-level override -- test-only helper. */
|
|
16
|
+
export declare function __resetScratchDirForTests(): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_SCRATCH_DIR = void 0;
|
|
37
|
+
exports.setScratchDir = setScratchDir;
|
|
38
|
+
exports.getScratchDir = getScratchDir;
|
|
39
|
+
exports.casesDir = casesDir;
|
|
40
|
+
exports.attachmentsDir = attachmentsDir;
|
|
41
|
+
exports.hashFullName = hashFullName;
|
|
42
|
+
exports.__resetScratchDirForTests = __resetScratchDirForTests;
|
|
43
|
+
const crypto = __importStar(require("crypto"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
exports.DEFAULT_SCRATCH_DIR = '.testpulse';
|
|
46
|
+
let scratchDir = exports.DEFAULT_SCRATCH_DIR;
|
|
47
|
+
/** Overrides the scratch directory path. Exposed for the reporter's config resolution. */
|
|
48
|
+
function setScratchDir(dir) {
|
|
49
|
+
scratchDir = dir;
|
|
50
|
+
}
|
|
51
|
+
function getScratchDir() {
|
|
52
|
+
return scratchDir;
|
|
53
|
+
}
|
|
54
|
+
function casesDir(base = scratchDir) {
|
|
55
|
+
return path.join(base, 'cases');
|
|
56
|
+
}
|
|
57
|
+
function attachmentsDir(base = scratchDir) {
|
|
58
|
+
return path.join(base, 'attachments');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Sidecar filenames are a hash of the fullName, never the fullName itself
|
|
62
|
+
* or any derivative of it used as a path component -- Jest test titles are
|
|
63
|
+
* fully user-controlled strings with no character restrictions, so a name
|
|
64
|
+
* containing `/` or `..` must not be able to influence where the sidecar
|
|
65
|
+
* is written.
|
|
66
|
+
*/
|
|
67
|
+
function hashFullName(fullName) {
|
|
68
|
+
return crypto.createHash('sha256').update(fullName, 'utf8').digest('hex');
|
|
69
|
+
}
|
|
70
|
+
/** Reset the module-level override -- test-only helper. */
|
|
71
|
+
function __resetScratchDirForTests() {
|
|
72
|
+
scratchDir = exports.DEFAULT_SCRATCH_DIR;
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { CaseOptions } from './caseStore';
|
|
2
|
+
import { TestFn } from './jestState';
|
|
3
|
+
export type TestpulseOptions = CaseOptions;
|
|
4
|
+
/**
|
|
5
|
+
* Wraps `test`/`it`, tagging the test with a TestPulse case key without
|
|
6
|
+
* altering its visible title -- the title/AssertionResult Jest itself
|
|
7
|
+
* produces is identical to an unwrapped call with the same name.
|
|
8
|
+
*
|
|
9
|
+
* Writes the case-key sidecar TWICE, deliberately: once synchronously at
|
|
10
|
+
* registration time (using the tracked describe-nesting stack, so a test
|
|
11
|
+
* that never actually runs -- filtered out by `-t`, shadowed by a sibling
|
|
12
|
+
* `.only`, cut short by `--bail`, a partial watch-mode run -- still gets
|
|
13
|
+
* linked, rather than silently losing its case key and showing up as a
|
|
14
|
+
* spurious "unmatched" entry) and again at execution time if the test
|
|
15
|
+
* body does run (using `expect.getState().currentTestName`, which is
|
|
16
|
+
* authoritative and correct even in the one case the tracked stack can't
|
|
17
|
+
* see: `describe.each` -- see describeStack.ts). The execution-time write
|
|
18
|
+
* is a same-data no-op in the common case, and a correcting overwrite in
|
|
19
|
+
* that one edge case.
|
|
20
|
+
*/
|
|
21
|
+
export declare function testpulse(caseKey: string, opts?: TestpulseOptions): (name: string, fn: TestFn, timeout?: number) => void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.testpulse = testpulse;
|
|
4
|
+
const scratchDir_1 = require("./scratchDir");
|
|
5
|
+
const caseStore_1 = require("./caseStore");
|
|
6
|
+
const jestState_1 = require("./jestState");
|
|
7
|
+
const describeStack_1 = require("./describeStack");
|
|
8
|
+
// Patches global describe() at MODULE LOAD time (a side effect of
|
|
9
|
+
// `require('testpulse-jest')`), not lazily on first testpulse() call --
|
|
10
|
+
// describe blocks run synchronously top-down during Jest's collection
|
|
11
|
+
// phase, so by the time any nested testpulse() call could trigger a lazy
|
|
12
|
+
// patch, the outermost describe() in the file would already have run
|
|
13
|
+
// unpatched. Real consumers import this package before defining any
|
|
14
|
+
// describe()/test() blocks, so module-load-time patching is early enough.
|
|
15
|
+
(0, describeStack_1.ensureDescribeTracked)();
|
|
16
|
+
/**
|
|
17
|
+
* Wraps `test`/`it`, tagging the test with a TestPulse case key without
|
|
18
|
+
* altering its visible title -- the title/AssertionResult Jest itself
|
|
19
|
+
* produces is identical to an unwrapped call with the same name.
|
|
20
|
+
*
|
|
21
|
+
* Writes the case-key sidecar TWICE, deliberately: once synchronously at
|
|
22
|
+
* registration time (using the tracked describe-nesting stack, so a test
|
|
23
|
+
* that never actually runs -- filtered out by `-t`, shadowed by a sibling
|
|
24
|
+
* `.only`, cut short by `--bail`, a partial watch-mode run -- still gets
|
|
25
|
+
* linked, rather than silently losing its case key and showing up as a
|
|
26
|
+
* spurious "unmatched" entry) and again at execution time if the test
|
|
27
|
+
* body does run (using `expect.getState().currentTestName`, which is
|
|
28
|
+
* authoritative and correct even in the one case the tracked stack can't
|
|
29
|
+
* see: `describe.each` -- see describeStack.ts). The execution-time write
|
|
30
|
+
* is a same-data no-op in the common case, and a correcting overwrite in
|
|
31
|
+
* that one edge case.
|
|
32
|
+
*/
|
|
33
|
+
function testpulse(caseKey, opts = {}) {
|
|
34
|
+
if (typeof caseKey !== 'string' || caseKey.length === 0) {
|
|
35
|
+
throw new Error(`testpulse-jest: testpulse() requires a non-empty case key string, got: ${JSON.stringify(caseKey)}`);
|
|
36
|
+
}
|
|
37
|
+
return function registerTest(name, fn, timeout) {
|
|
38
|
+
(0, caseStore_1.writeCaseMetadata)((0, scratchDir_1.getScratchDir)(), (0, jestState_1.reconstructFullName)((0, describeStack_1.getDescribeStack)(), name), caseKey, opts);
|
|
39
|
+
const registrar = (0, jestState_1.getGlobalTestRegistrar)();
|
|
40
|
+
registrar(name, async (...args) => {
|
|
41
|
+
(0, caseStore_1.writeCaseMetadata)((0, scratchDir_1.getScratchDir)(), (0, jestState_1.getCurrentTestName)(), caseKey, opts);
|
|
42
|
+
return fn(...args);
|
|
43
|
+
}, timeout);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface TestpulseAttachOptions {
|
|
2
|
+
filename: string;
|
|
3
|
+
contentType: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Registers an attachment for the currently-running testpulse()-wrapped
|
|
7
|
+
* test. Content-type validation happens synchronously (before any I/O),
|
|
8
|
+
* so an invalid content type throws immediately even though the overall
|
|
9
|
+
* function returns a Promise for the (potentially async) file read.
|
|
10
|
+
*/
|
|
11
|
+
export declare function testpulseAttach(bufferOrPath: Buffer | string, opts: TestpulseAttachOptions): Promise<void>;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.testpulseAttach = testpulseAttach;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const scratchDir_1 = require("./scratchDir");
|
|
39
|
+
const caseStore_1 = require("./caseStore");
|
|
40
|
+
const attachmentStore_1 = require("./attachmentStore");
|
|
41
|
+
const jestState_1 = require("./jestState");
|
|
42
|
+
/**
|
|
43
|
+
* Registers an attachment for the currently-running testpulse()-wrapped
|
|
44
|
+
* test. Content-type validation happens synchronously (before any I/O),
|
|
45
|
+
* so an invalid content type throws immediately even though the overall
|
|
46
|
+
* function returns a Promise for the (potentially async) file read.
|
|
47
|
+
*/
|
|
48
|
+
function testpulseAttach(bufferOrPath, opts) {
|
|
49
|
+
if (!(0, attachmentStore_1.isSupportedContentType)(opts.contentType)) {
|
|
50
|
+
throw new Error(`testpulse-jest: testpulseAttach() only supports ${attachmentStore_1.SUPPORTED_CONTENT_TYPES.join(', ')}, got: ${opts.contentType}`);
|
|
51
|
+
}
|
|
52
|
+
return attach(bufferOrPath, opts);
|
|
53
|
+
}
|
|
54
|
+
async function attach(bufferOrPath, opts) {
|
|
55
|
+
const scratchDir = (0, scratchDir_1.getScratchDir)();
|
|
56
|
+
const fullName = (0, jestState_1.getCurrentTestName)();
|
|
57
|
+
const existingCase = (0, caseStore_1.readCaseMetadata)(scratchDir, fullName);
|
|
58
|
+
if (!existingCase) {
|
|
59
|
+
// eslint-disable-next-line no-console
|
|
60
|
+
console.warn(`testpulse-jest: testpulseAttach() called from "${fullName}", which has no testpulse() case key -- dropping the attachment.`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const data = Buffer.isBuffer(bufferOrPath) ? bufferOrPath : await fs.promises.readFile(bufferOrPath);
|
|
64
|
+
(0, attachmentStore_1.writeAttachment)(scratchDir, fullName, data, opts.filename, opts.contentType);
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "testpulse-jest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Jest reporter + jest-junit companion for reporting test results into TestPulse",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Barayo/testpulse-jest.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Barayo/testpulse-jest#readme",
|
|
11
|
+
"bugs": "https://github.com/Barayo/testpulse-jest/issues",
|
|
12
|
+
"keywords": ["jest", "jest-reporter", "testpulse", "junit", "test-management"],
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"require": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./junitTestCaseProperties": {
|
|
21
|
+
"types": "./dist/junitTestCaseProperties.d.ts",
|
|
22
|
+
"require": "./dist/junitTestCaseProperties.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": ["dist"],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.build.json",
|
|
31
|
+
"test": "jest --selectProjects unit",
|
|
32
|
+
"test:e2e": "npm run build && jest --selectProjects e2e",
|
|
33
|
+
"test:all": "npm run build && jest",
|
|
34
|
+
"lint": "tsc --noEmit",
|
|
35
|
+
"prepublishOnly": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"jest-junit": ">=11"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@semantic-release/exec": "^6.0.3",
|
|
42
|
+
"@semantic-release/git": "^10.0.1",
|
|
43
|
+
"@types/jest": "^29.5.14",
|
|
44
|
+
"@types/node": "^20.17.9",
|
|
45
|
+
"jest": "^29.7.0",
|
|
46
|
+
"jest-junit": "^16.0.0",
|
|
47
|
+
"msw": "^2.6.6",
|
|
48
|
+
"semantic-release": "^24.2.0",
|
|
49
|
+
"ts-jest": "^29.2.5",
|
|
50
|
+
"typescript": "^5.7.2"
|
|
51
|
+
}
|
|
52
|
+
}
|