@postalsys/email-text-tools 2.4.3 → 2.4.5

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.
@@ -0,0 +1,22 @@
1
+ name: Run tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ strategy:
10
+ matrix:
11
+ node: [20.x, 22.x, 24.x]
12
+ os: [ubuntu-latest]
13
+ runs-on: ${{ matrix.os }}
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - name: Use Node.js ${{ matrix.node }}
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: ${{ matrix.node }}
20
+ - run: npm install
21
+ - run: npm run lint
22
+ - run: npm test
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "2.4.3"
2
+ ".": "2.4.5"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.5](https://github.com/postalsys/email-text-tools/compare/v2.4.4...v2.4.5) (2026-04-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * Bumped deps ([feceb0b](https://github.com/postalsys/email-text-tools/commit/feceb0b8d1dc6b8e74c40e54e60730ef43d5e8f9))
9
+
10
+ ## [2.4.4](https://github.com/postalsys/email-text-tools/compare/v2.4.3...v2.4.4) (2026-04-08)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * Bumped deps ([fd057e4](https://github.com/postalsys/email-text-tools/commit/fd057e436e693bf612bae28458e1e74c38739caa))
16
+
3
17
  ## [2.4.3](https://github.com/postalsys/email-text-tools/compare/v2.4.2...v2.4.3) (2026-03-15)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postalsys/email-text-tools",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -26,18 +26,18 @@
26
26
  "homepage": "https://github.com/postalsys/email-text-tools#readme",
27
27
  "devDependencies": {
28
28
  "@eslint/js": "10.0.1",
29
- "eslint": "10.0.3",
29
+ "eslint": "10.2.0",
30
30
  "eslint-config-prettier": "10.1.8",
31
- "prettier": "3.8.1"
31
+ "prettier": "3.8.3"
32
32
  },
33
33
  "dependencies": {
34
- "dompurify": "3.3.3",
34
+ "dompurify": "3.4.0",
35
35
  "he": "1.2.0",
36
36
  "html-to-text": "9.0.5",
37
37
  "jsdom": "27.0.0",
38
38
  "juice": "11.1.1",
39
39
  "linkify-it": "5.0.0",
40
- "moment-timezone": "0.6.0",
40
+ "moment-timezone": "0.6.1",
41
41
  "node-html-parser": "7.1.0",
42
42
  "tlds": "1.261.0"
43
43
  }
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const inlineHtml = require('../lib/inline-html');
6
+
7
+ describe('inline-html', () => {
8
+ const baseDate = new Date('2024-06-15T14:30:00Z');
9
+
10
+ const fullMessageData = {
11
+ from: [{ name: 'Alice Sender', address: 'alice@example.com' }],
12
+ subject: 'Test Subject',
13
+ date: baseDate,
14
+ to: [{ name: 'Bob Receiver', address: 'bob@example.com' }],
15
+ cc: [{ name: 'Charlie', address: 'charlie@example.com' }],
16
+ bcc: [{ address: 'secret@example.com' }],
17
+ html: '<html><body><p>Original message</p></body></html>'
18
+ };
19
+
20
+ describe('reply action', () => {
21
+ it('should create reply with date and from header', () => {
22
+ const result = inlineHtml('reply', '<p>My reply</p>', fullMessageData);
23
+ assert.ok(result.includes('wrote:'));
24
+ assert.ok(result.includes('alice@example.com'));
25
+ assert.ok(result.includes('My reply'));
26
+ assert.ok(result.includes('<blockquote'));
27
+ assert.ok(result.includes('Original message'));
28
+ });
29
+
30
+ it('should create reply with no from address using <> fallback', () => {
31
+ const data = { date: baseDate, html: '<p>Body</p>' };
32
+ const result = inlineHtml('reply', '', data);
33
+ assert.ok(result.includes('&lt;&gt;'));
34
+ assert.ok(result.includes('wrote:'));
35
+ });
36
+
37
+ it('should create reply with no date', () => {
38
+ const data = {
39
+ from: [{ name: 'Sender', address: 's@test.com' }],
40
+ html: '<p>Body</p>'
41
+ };
42
+ const result = inlineHtml('reply', '', data);
43
+ assert.ok(result.includes('wrote:'));
44
+ assert.ok(!result.includes('undefined'));
45
+ });
46
+ });
47
+
48
+ describe('forward action', () => {
49
+ it('should create forward with all headers', () => {
50
+ const result = inlineHtml('forward', '<p>FYI</p>', fullMessageData);
51
+ assert.ok(result.includes('Begin forwarded message:'));
52
+ assert.ok(result.includes('<b>From: </b>'));
53
+ assert.ok(result.includes('<b>Subject: </b>'));
54
+ assert.ok(result.includes('<b>Date: </b>'));
55
+ assert.ok(result.includes('<b>To: </b>'));
56
+ assert.ok(result.includes('<b>Cc: </b>'));
57
+ assert.ok(result.includes('<b>Bcc: </b>'));
58
+ assert.ok(result.includes('FYI'));
59
+ assert.ok(result.includes('Original message'));
60
+ });
61
+
62
+ it('should include from fallback in forward with no from', () => {
63
+ const data = { subject: 'Fwd', html: '<p>Body</p>' };
64
+ const result = inlineHtml('forward', '', data);
65
+ assert.ok(result.includes('Begin forwarded message:'));
66
+ assert.ok(!result.includes('<b>From: </b>'));
67
+ });
68
+ });
69
+
70
+ describe('unknown action', () => {
71
+ it('should include original body in blockquote without header lines', () => {
72
+ const data = { html: '<p>Body</p>' };
73
+ const result = inlineHtml('draft', '', data);
74
+ assert.ok(result.includes('<blockquote'));
75
+ assert.ok(result.includes('Body'));
76
+ assert.ok(!result.includes('wrote:'));
77
+ assert.ok(!result.includes('Begin forwarded message:'));
78
+ });
79
+ });
80
+
81
+ describe('original body extraction', () => {
82
+ it('should use textToHtml when messageData has text but no html', () => {
83
+ const data = { text: 'Plain text original' };
84
+ const result = inlineHtml('reply', '', data);
85
+ assert.ok(result.includes('Plain text original'));
86
+ assert.ok(result.includes('<blockquote'));
87
+ });
88
+
89
+ it('should extract body innerHTML when messageData has html', () => {
90
+ const data = { html: '<html><body><p>Extracted</p></body></html>' };
91
+ const result = inlineHtml('reply', '', data);
92
+ assert.ok(result.includes('<p>Extracted</p>'));
93
+ assert.ok(!result.includes('<body>'));
94
+ });
95
+
96
+ it('should extract content when html has no body tag', () => {
97
+ const data = { html: '<p>No body tag</p>' };
98
+ const result = inlineHtml('reply', '', data);
99
+ assert.ok(result.includes('No body tag'));
100
+ });
101
+
102
+ it('should return messageContent unchanged when no originalBody', () => {
103
+ const data = { text: '', html: '' };
104
+ const result = inlineHtml('reply', 'Untouched', data);
105
+ assert.equal(result, 'Untouched');
106
+ });
107
+
108
+ it('should return messageContent when messageData has no text or html', () => {
109
+ const result = inlineHtml('reply', 'Keep this', {});
110
+ assert.equal(result, 'Keep this');
111
+ });
112
+ });
113
+
114
+ describe('address formatting', () => {
115
+ it('should format address with both name and address', () => {
116
+ const data = {
117
+ from: [{ name: 'Full Name', address: 'full@test.com' }],
118
+ html: '<p>Body</p>'
119
+ };
120
+ const result = inlineHtml('forward', '', data);
121
+ assert.ok(result.includes('Full Name'));
122
+ assert.ok(result.includes('mailto:full@test.com'));
123
+ });
124
+
125
+ it('should format address with name only', () => {
126
+ const data = {
127
+ from: [{ name: 'Name Only' }],
128
+ html: '<p>Body</p>'
129
+ };
130
+ const result = inlineHtml('forward', '', data);
131
+ assert.ok(result.includes('Name Only'));
132
+ assert.ok(!result.includes('mailto:'));
133
+ });
134
+
135
+ it('should format address with address only', () => {
136
+ const data = {
137
+ from: [{ address: 'addr@test.com' }],
138
+ html: '<p>Body</p>'
139
+ };
140
+ const result = inlineHtml('forward', '', data);
141
+ assert.ok(result.includes('mailto:addr@test.com'));
142
+ });
143
+
144
+ it('should use <> fallback for empty address object', () => {
145
+ const data = {
146
+ from: [{}],
147
+ html: '<p>Body</p>'
148
+ };
149
+ const result = inlineHtml('reply', '', data);
150
+ assert.ok(result.includes('&lt;&gt;'));
151
+ });
152
+
153
+ it('should format multiple addresses separated by commas', () => {
154
+ const data = {
155
+ to: [
156
+ { name: 'A', address: 'a@t.com' },
157
+ { name: 'B', address: 'b@t.com' }
158
+ ],
159
+ html: '<p>Body</p>'
160
+ };
161
+ const result = inlineHtml('forward', '', data);
162
+ assert.ok(result.includes('a@t.com'));
163
+ assert.ok(result.includes('b@t.com'));
164
+ });
165
+
166
+ it('should skip invalid entries in address list', () => {
167
+ const data = {
168
+ to: [null, {}, { name: 'Valid', address: 'v@t.com' }],
169
+ html: '<p>Body</p>'
170
+ };
171
+ const result = inlineHtml('forward', '', data);
172
+ assert.ok(result.includes('v@t.com'));
173
+ });
174
+
175
+ it('should omit header for empty address list result', () => {
176
+ const data = {
177
+ to: [{}],
178
+ html: '<p>Body</p>'
179
+ };
180
+ const result = inlineHtml('forward', '', data);
181
+ assert.ok(!result.includes('<b>To: </b>'));
182
+ });
183
+ });
184
+
185
+ describe('date formatting', () => {
186
+ it('should format date with locale option', () => {
187
+ const data = { date: baseDate, html: '<p>Body</p>' };
188
+ const result = inlineHtml('forward', '', data, { locale: 'de' });
189
+ assert.ok(result.includes('<b>Date: </b>'));
190
+ });
191
+
192
+ it('should format date with timezone option', () => {
193
+ const data = { date: baseDate, html: '<p>Body</p>' };
194
+ const result = inlineHtml('forward', '', data, { tz: 'America/New_York' });
195
+ assert.ok(result.includes('<b>Date: </b>'));
196
+ });
197
+
198
+ it('should format date with both locale and timezone', () => {
199
+ const data = { date: baseDate, html: '<p>Body</p>' };
200
+ const result = inlineHtml('forward', '', data, { locale: 'en', tz: 'UTC' });
201
+ assert.ok(result.includes('<b>Date: </b>'));
202
+ });
203
+ });
204
+
205
+ describe('messageContent handling', () => {
206
+ it('should handle null messageContent', () => {
207
+ const data = { html: '<p>Body</p>' };
208
+ const result = inlineHtml('reply', null, data);
209
+ assert.ok(result.includes('<html>'));
210
+ assert.ok(result.includes('Body'));
211
+ });
212
+
213
+ it('should extract body from full HTML messageContent', () => {
214
+ const data = { html: '<p>Original</p>' };
215
+ const result = inlineHtml('reply', '<html><body><p>Reply content</p></body></html>', data);
216
+ assert.ok(result.includes('Reply content'));
217
+ });
218
+ });
219
+
220
+ describe('output structure', () => {
221
+ it('should produce valid HTML document structure', () => {
222
+ const result = inlineHtml('reply', 'Reply', fullMessageData);
223
+ assert.ok(result.includes('<html>'));
224
+ assert.ok(result.includes('<head>'));
225
+ assert.ok(result.includes('charset=utf-8'));
226
+ assert.ok(result.includes('<body'));
227
+ assert.ok(result.includes('</html>'));
228
+ });
229
+ });
230
+ });
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const inlineText = require('../lib/inline-text');
6
+
7
+ describe('inline-text', () => {
8
+ const baseDate = new Date('2024-06-15T14:30:00Z');
9
+
10
+ const fullMessageData = {
11
+ from: [{ name: 'Alice Sender', address: 'alice@example.com' }],
12
+ subject: 'Test Subject',
13
+ date: baseDate,
14
+ to: [{ name: 'Bob', address: 'bob@example.com' }],
15
+ cc: [{ name: 'Charlie', address: 'charlie@example.com' }],
16
+ bcc: [{ address: 'secret@example.com' }],
17
+ text: 'Original message text'
18
+ };
19
+
20
+ describe('reply action', () => {
21
+ it('should create reply with "On date, from wrote:" header', () => {
22
+ const result = inlineText('reply', 'My reply', fullMessageData);
23
+ assert.ok(result.includes('My reply'));
24
+ assert.ok(result.includes('> On '));
25
+ assert.ok(result.includes('wrote:'));
26
+ assert.ok(result.includes('alice@example.com'));
27
+ assert.ok(result.includes('> Original message text'));
28
+ });
29
+
30
+ it('should create reply with no from using <> fallback', () => {
31
+ const data = { date: baseDate, text: 'Body text' };
32
+ const result = inlineText('reply', '', data);
33
+ assert.ok(result.includes('<>'));
34
+ assert.ok(result.includes('wrote:'));
35
+ });
36
+
37
+ it('should include original body with > quoting', () => {
38
+ const data = { text: 'Line 1\nLine 2\nLine 3' };
39
+ const result = inlineText('reply', 'Reply', data);
40
+ assert.ok(result.includes('> Line 1'));
41
+ assert.ok(result.includes('> Line 2'));
42
+ assert.ok(result.includes('> Line 3'));
43
+ });
44
+ });
45
+
46
+ describe('forward action', () => {
47
+ it('should create forward with all headers', () => {
48
+ const result = inlineText('forward', 'FYI', fullMessageData);
49
+ assert.ok(result.includes('> Begin forwarded message:'));
50
+ assert.ok(result.includes('> From:'));
51
+ assert.ok(result.includes('> Subject: Test Subject'));
52
+ assert.ok(result.includes('> Date:'));
53
+ assert.ok(result.includes('> To:'));
54
+ assert.ok(result.includes('> Cc:'));
55
+ assert.ok(result.includes('> Bcc:'));
56
+ assert.ok(result.includes('> Original message text'));
57
+ });
58
+
59
+ it('should omit from header when no from provided', () => {
60
+ const data = { subject: 'Fwd', text: 'Body' };
61
+ const result = inlineText('forward', '', data);
62
+ assert.ok(result.includes('> Begin forwarded message:'));
63
+ assert.ok(result.includes('> Subject: Fwd'));
64
+ assert.ok(!result.includes('> From:'));
65
+ });
66
+ });
67
+
68
+ describe('unknown action', () => {
69
+ it('should include original body without typed headers', () => {
70
+ const data = { text: 'Body text' };
71
+ const result = inlineText('other', '', data);
72
+ assert.ok(result.includes('> Body text'));
73
+ assert.ok(!result.includes('wrote:'));
74
+ assert.ok(!result.includes('Begin forwarded message:'));
75
+ });
76
+ });
77
+
78
+ describe('original body source', () => {
79
+ it('should use htmlToText when messageData has html but no text', () => {
80
+ const data = { html: '<p>HTML content</p>' };
81
+ const result = inlineText('reply', '', data);
82
+ assert.ok(result.includes('HTML content'));
83
+ });
84
+
85
+ it('should use text directly when available', () => {
86
+ const data = { text: 'Direct text', html: '<p>HTML</p>' };
87
+ const result = inlineText('reply', '', data);
88
+ assert.ok(result.includes('> Direct text'));
89
+ });
90
+
91
+ it('should return messageContent unchanged when no originalBody', () => {
92
+ const result = inlineText('reply', 'Keep this', { text: '' });
93
+ assert.equal(result, 'Keep this');
94
+ });
95
+
96
+ it('should return messageContent when messageData is empty', () => {
97
+ const result = inlineText('reply', 'Keep', {});
98
+ assert.equal(result, 'Keep');
99
+ });
100
+ });
101
+
102
+ describe('address formatting', () => {
103
+ it('should format address with name and address', () => {
104
+ const data = {
105
+ from: [{ name: 'Full Name', address: 'full@test.com' }],
106
+ text: 'Body'
107
+ };
108
+ const result = inlineText('forward', '', data);
109
+ assert.ok(result.includes('Full Name <full@test.com>'));
110
+ });
111
+
112
+ it('should format address with name only', () => {
113
+ const data = {
114
+ from: [{ name: 'Name Only' }],
115
+ text: 'Body'
116
+ };
117
+ const result = inlineText('forward', '', data);
118
+ assert.ok(result.includes('Name Only'));
119
+ assert.ok(!result.includes('<>'));
120
+ });
121
+
122
+ it('should format address with address only', () => {
123
+ const data = {
124
+ from: [{ address: 'addr@test.com' }],
125
+ text: 'Body'
126
+ };
127
+ const result = inlineText('forward', '', data);
128
+ assert.ok(result.includes('<addr@test.com>'));
129
+ });
130
+
131
+ it('should use <> fallback for empty address object', () => {
132
+ const data = {
133
+ from: [{}],
134
+ text: 'Body'
135
+ };
136
+ const result = inlineText('reply', '', data);
137
+ assert.ok(result.includes('<>'));
138
+ });
139
+
140
+ it('should format multiple addresses separated by commas', () => {
141
+ const data = {
142
+ to: [
143
+ { name: 'A', address: 'a@t.com' },
144
+ { name: 'B', address: 'b@t.com' }
145
+ ],
146
+ text: 'Body'
147
+ };
148
+ const result = inlineText('forward', '', data);
149
+ assert.ok(result.includes('A <a@t.com>'));
150
+ assert.ok(result.includes('B <b@t.com>'));
151
+ });
152
+
153
+ it('should skip invalid entries in address list', () => {
154
+ const data = {
155
+ to: [null, {}, { name: 'Valid', address: 'v@t.com' }],
156
+ text: 'Body'
157
+ };
158
+ const result = inlineText('forward', '', data);
159
+ assert.ok(result.includes('Valid <v@t.com>'));
160
+ });
161
+
162
+ it('should omit header for empty address list result', () => {
163
+ const data = {
164
+ to: [{}],
165
+ text: 'Body'
166
+ };
167
+ const result = inlineText('forward', '', data);
168
+ assert.ok(!result.includes('> To:'));
169
+ });
170
+ });
171
+
172
+ describe('escapeText', () => {
173
+ it('should handle \\r\\n line endings', () => {
174
+ const data = { text: 'Line1\r\nLine2\r\nLine3' };
175
+ const result = inlineText('reply', '', data);
176
+ assert.ok(result.includes('> Line1'));
177
+ assert.ok(result.includes('> Line2'));
178
+ assert.ok(result.includes('> Line3'));
179
+ });
180
+ });
181
+
182
+ describe('date formatting', () => {
183
+ it('should format date with locale', () => {
184
+ const data = { date: baseDate, text: 'Body' };
185
+ const result = inlineText('forward', '', data, { locale: 'de' });
186
+ assert.ok(result.includes('> Date:'));
187
+ });
188
+
189
+ it('should format date with timezone', () => {
190
+ const data = { date: baseDate, text: 'Body' };
191
+ const result = inlineText('forward', '', data, { tz: 'America/New_York' });
192
+ assert.ok(result.includes('> Date:'));
193
+ });
194
+ });
195
+
196
+ describe('messageContent handling', () => {
197
+ it('should handle null messageContent', () => {
198
+ const data = { text: 'Body' };
199
+ const result = inlineText('reply', null, data);
200
+ assert.ok(typeof result === 'string');
201
+ assert.ok(result.includes('> Body'));
202
+ });
203
+ });
204
+ });
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const mimeHtml = require('../lib/mime-html');
6
+
7
+ describe('mime-html additional coverage', () => {
8
+ after(async () => {
9
+ await mimeHtml.closeWorkerPool();
10
+ });
11
+
12
+ describe('getWorkerPoolStats', () => {
13
+ it('should return null when no pool is initialized', async () => {
14
+ await mimeHtml.closeWorkerPool();
15
+ const stats = mimeHtml.getWorkerPoolStats();
16
+ assert.equal(stats, null);
17
+ });
18
+ });
19
+
20
+ describe('pool fallback to single worker', () => {
21
+ it('should still return result when pool times out and falls back', async () => {
22
+ await mimeHtml.closeWorkerPool();
23
+
24
+ // Use timeout: 1 to force pool timeout, triggering fallback to single worker
25
+ // Both may time out, but fallbackOnError defaults to true so processing continues
26
+ const result = await mimeHtml.async(
27
+ { html: '<p>Fallback test</p>' },
28
+ {
29
+ timeout: 1,
30
+ useWorkerPool: true
31
+ }
32
+ );
33
+
34
+ // Should still get a result (with or without inlined CSS)
35
+ assert.ok(result.includes('Fallback test'));
36
+ });
37
+ });
38
+ });
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const textToHtml = require('../lib/text-to-html');
6
+
7
+ describe('text-to-html additional coverage', () => {
8
+ describe('trailing quoted lines', () => {
9
+ it('should handle text ending with quoted lines', () => {
10
+ const result = textToHtml('Normal line\n> Quoted at end\n> Still quoted');
11
+ assert.ok(result.includes('<blockquote'));
12
+ assert.ok(result.includes('Quoted at end'));
13
+ assert.ok(result.includes('Still quoted'));
14
+ assert.ok(result.includes('Normal line'));
15
+ });
16
+ });
17
+
18
+ describe('horizontal rule detection', () => {
19
+ it('should convert --- to <hr /> when followed by non-empty line', () => {
20
+ const result = textToHtml('Before\n---\nAfter');
21
+ assert.ok(result.includes('<hr />'));
22
+ assert.ok(result.includes('Before'));
23
+ assert.ok(result.includes('After'));
24
+ });
25
+
26
+ it('should convert --- to <hr /> when it is the last line', () => {
27
+ const result = textToHtml('Before\n---');
28
+ assert.ok(result.includes('<hr />'));
29
+ assert.ok(result.includes('Before'));
30
+ });
31
+
32
+ it('should convert ___ to <hr />', () => {
33
+ const result = textToHtml('Before\n___\nAfter');
34
+ assert.ok(result.includes('<hr />'));
35
+ });
36
+
37
+ it('should convert --- to <hr /> when followed by empty line', () => {
38
+ const result = textToHtml('Before\n---\n');
39
+ assert.ok(result.includes('<hr />'));
40
+ });
41
+ });
42
+ });
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, before, after, afterEach } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const WorkerPool = require('../lib/worker-pool');
8
+
9
+ const crashWorkerPath = path.join(__dirname, 'crash-test-worker.js');
10
+ const crashWorkerCode = `
11
+ 'use strict';
12
+ const { parentPort } = require('worker_threads');
13
+ parentPort.on('message', (data) => {
14
+ if (data === 'crash') {
15
+ setTimeout(() => { throw new Error('intentional crash'); }, 0);
16
+ } else if (data === 'slow') {
17
+ setTimeout(() => {
18
+ parentPort.postMessage({ success: true, result: 'slow: ' + data });
19
+ }, 50);
20
+ } else {
21
+ parentPort.postMessage({ success: true, result: 'ok: ' + data });
22
+ }
23
+ });
24
+ `;
25
+
26
+ describe('WorkerPool additional coverage', () => {
27
+ let pool;
28
+
29
+ before(() => {
30
+ fs.writeFileSync(crashWorkerPath, crashWorkerCode);
31
+ });
32
+
33
+ after(() => {
34
+ try {
35
+ fs.unlinkSync(crashWorkerPath);
36
+ } catch (_err) {
37
+ // ignore cleanup errors
38
+ }
39
+ });
40
+
41
+ afterEach(async () => {
42
+ if (pool) {
43
+ await pool.close();
44
+ pool = null;
45
+ }
46
+ });
47
+
48
+ describe('worker error handling', () => {
49
+ it('should replace a crashed worker and remain functional', async () => {
50
+ pool = new WorkerPool({
51
+ workerPath: crashWorkerPath,
52
+ minWorkers: 2,
53
+ maxWorkers: 3
54
+ });
55
+
56
+ // Verify initial state
57
+ let stats = pool.getStats();
58
+ assert.equal(stats.totalWorkers, 2);
59
+
60
+ // Trigger a crash
61
+ try {
62
+ await pool.process('crash');
63
+ assert.fail('Should have rejected');
64
+ } catch (err) {
65
+ assert.ok(err);
66
+ }
67
+
68
+ // Wait for replacement worker to be created
69
+ await new Promise(r => setTimeout(r, 300));
70
+
71
+ // Pool should have replaced the crashed worker
72
+ stats = pool.getStats();
73
+ assert.ok(stats.totalWorkers >= 2, `Expected >= 2 workers but got ${stats.totalWorkers}`);
74
+
75
+ // Pool should still be functional
76
+ const result = await pool.process('hello');
77
+ assert.equal(result, 'ok: hello');
78
+ });
79
+ });
80
+
81
+ describe('idle timeout', () => {
82
+ it('should terminate idle worker above minimum via _setupIdleTimeout', async () => {
83
+ pool = new WorkerPool({
84
+ workerPath: crashWorkerPath,
85
+ minWorkers: 1,
86
+ maxWorkers: 3,
87
+ idleTimeout: 200
88
+ });
89
+
90
+ assert.equal(pool.getStats().totalWorkers, 1);
91
+
92
+ // Manually create an extra worker above minWorkers (without a task).
93
+ // This triggers _setupIdleTimeout in _createWorker.
94
+ pool._createWorker();
95
+
96
+ assert.equal(pool.getStats().totalWorkers, 2);
97
+
98
+ // Wait for idle timeout to fire and remove the extra worker
99
+ await new Promise(r => setTimeout(r, 400));
100
+
101
+ assert.equal(pool.getStats().totalWorkers, 1);
102
+ });
103
+ });
104
+
105
+ describe('close with active idle timeouts', () => {
106
+ it('should close cleanly when workers have pending idle timeouts', async () => {
107
+ pool = new WorkerPool({
108
+ workerPath: crashWorkerPath,
109
+ minWorkers: 1,
110
+ maxWorkers: 3,
111
+ idleTimeout: 10000
112
+ });
113
+
114
+ // Scale up
115
+ const promises = [];
116
+ for (let i = 0; i < 3; i++) {
117
+ promises.push(pool.process(`task-${i}`));
118
+ }
119
+ await Promise.all(promises);
120
+
121
+ // Workers above min should have idle timeouts set
122
+ let stats = pool.getStats();
123
+ assert.ok(stats.totalWorkers > 1);
124
+
125
+ // Close immediately before idle timeout fires
126
+ await pool.close();
127
+ pool = null;
128
+
129
+ // Should not throw or leak timers
130
+ });
131
+ });
132
+ });