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 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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }