start-command 0.26.0 → 0.27.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +1 -1
  3. package/bunfig.toml +2 -2
  4. package/eslint.config.mjs +1 -1
  5. package/package.json +2 -2
  6. package/src/bin/cli.js +30 -0
  7. package/src/lib/args-parser.js +72 -6
  8. package/src/lib/execution-control.js +317 -0
  9. package/src/lib/isolation.js +22 -4
  10. package/src/lib/status-formatter.js +46 -2
  11. package/src/lib/usage.js +5 -1
  12. package/test/args-parser-control.js +71 -0
  13. package/test/{args-parser.test.js → args-parser.js} +1 -1
  14. package/test/cli.js +260 -0
  15. package/test/create-github-release.mjs +118 -0
  16. package/test/docker-autoremove.js +175 -0
  17. package/test/execution-control.js +253 -0
  18. package/test/{isolation-cleanup.test.js → isolation-cleanup.js} +120 -109
  19. package/test/{isolation.test.js → isolation.js} +4 -2
  20. package/test/merge-changesets.mjs +154 -0
  21. package/test/publish-to-crates.mjs +194 -0
  22. package/test/release-name.mjs +117 -0
  23. package/test/{screen-integration.test.js → screen-integration.js} +1 -1
  24. package/test/{ssh-integration.test.js → ssh-integration.js} +1 -1
  25. package/test/{status-query.test.js → status-query.js} +2 -0
  26. package/test/{substitution.test.js → substitution.js} +1 -2
  27. package/test/{user-manager.test.js → user-manager.js} +17 -0
  28. package/test/cli.test.js +0 -218
  29. package/test/docker-autoremove.test.js +0 -164
  30. package/test/release-name.test.mjs +0 -34
  31. /package/test/{args-parser-shell.test.js → args-parser-shell.js} +0 -0
  32. /package/test/{echo-integration.test.js → echo-integration.js} +0 -0
  33. /package/test/{execution-store.test.js → execution-store.js} +0 -0
  34. /package/test/{failure-handler.test.js → failure-handler.js} +0 -0
  35. /package/test/{isolation-log-utils.test.js → isolation-log-utils.js} +0 -0
  36. /package/test/{isolation-stacking.test.js → isolation-stacking.js} +0 -0
  37. /package/test/{output-blocks.test.js → output-blocks.js} +0 -0
  38. /package/test/{public-exports.test.js → public-exports.js} +0 -0
  39. /package/test/{regression-84.test.js → regression-84.js} +0 -0
  40. /package/test/{regression-89.test.js → regression-89.js} +0 -0
  41. /package/test/{regression-91.test.js → regression-91.js} +0 -0
  42. /package/test/{sequence-parser.test.js → sequence-parser.js} +0 -0
  43. /package/test/{session-name-status.test.js → session-name-status.js} +0 -0
  44. /package/test/{version.test.js → version.js} +0 -0
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Unit tests for detached execution control helpers.
4
+ */
5
+
6
+ const { describe, it, beforeEach, afterEach } = require('node:test');
7
+ const assert = require('assert');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const {
13
+ ControlAction,
14
+ collectProcessIds,
15
+ controlExecution,
16
+ getControlCommand,
17
+ parseScreenPid,
18
+ } = require('../src/lib/execution-control');
19
+ const {
20
+ ExecutionRecord,
21
+ ExecutionStatus,
22
+ ExecutionStore,
23
+ } = require('../src/lib/execution-store');
24
+
25
+ const TEST_APP_FOLDER = path.join(
26
+ os.tmpdir(),
27
+ `execution-control-test-${Date.now()}`
28
+ );
29
+
30
+ function cleanupTestDir() {
31
+ if (fs.existsSync(TEST_APP_FOLDER)) {
32
+ fs.rmSync(TEST_APP_FOLDER, { recursive: true, force: true });
33
+ }
34
+ }
35
+
36
+ function createStore() {
37
+ return new ExecutionStore({
38
+ appFolder: TEST_APP_FOLDER,
39
+ useLinks: false,
40
+ });
41
+ }
42
+
43
+ function createDetachedRecord(overrides = {}) {
44
+ return new ExecutionRecord({
45
+ uuid: 'control-test-uuid',
46
+ command: 'sleep 100',
47
+ pid: 12345,
48
+ logPath: '/tmp/control-test.log',
49
+ status: ExecutionStatus.EXECUTING,
50
+ options: {
51
+ isolated: 'screen',
52
+ isolationMode: 'detached',
53
+ sessionName: 'screen-session',
54
+ },
55
+ ...overrides,
56
+ });
57
+ }
58
+
59
+ function createRunner(responses = {}) {
60
+ const calls = [];
61
+ const runner = (command, args) => {
62
+ calls.push({ command, args });
63
+ const key = `${command} ${args.join(' ')}`;
64
+ const response = responses[key] || responses[command];
65
+ if (typeof response === 'function') {
66
+ return response(command, args);
67
+ }
68
+ return (
69
+ response || {
70
+ success: true,
71
+ stdout: '',
72
+ stderr: '',
73
+ status: 0,
74
+ error: null,
75
+ }
76
+ );
77
+ };
78
+ runner.calls = calls;
79
+ return runner;
80
+ }
81
+
82
+ describe('execution control', () => {
83
+ beforeEach(cleanupTestDir);
84
+ afterEach(cleanupTestDir);
85
+
86
+ it('should map screen stop to CTRL+C injection', () => {
87
+ const record = createDetachedRecord();
88
+ const command = getControlCommand(record, ControlAction.STOP);
89
+
90
+ assert.strictEqual(command.command, 'screen');
91
+ assert.deepStrictEqual(command.args, [
92
+ '-S',
93
+ 'screen-session',
94
+ '-X',
95
+ 'stuff',
96
+ '\x03',
97
+ ]);
98
+ assert.strictEqual(command.method, 'CTRL_C');
99
+ });
100
+
101
+ it('should send stop command to a detached screen session', () => {
102
+ const store = createStore();
103
+ store.save(createDetachedRecord());
104
+ const runner = createRunner({
105
+ 'screen -ls': {
106
+ success: true,
107
+ stdout: '\t111.screen-session\t(Detached)\n',
108
+ stderr: '',
109
+ status: 0,
110
+ error: null,
111
+ },
112
+ 'pgrep -P 111': {
113
+ success: true,
114
+ stdout: '222\n',
115
+ stderr: '',
116
+ status: 0,
117
+ error: null,
118
+ },
119
+ });
120
+
121
+ const result = controlExecution(
122
+ store,
123
+ 'screen-session',
124
+ ControlAction.STOP,
125
+ runner
126
+ );
127
+
128
+ assert.strictEqual(result.success, true);
129
+ assert.match(result.output, /executionControl/);
130
+ assert.match(result.output, /action stop/);
131
+ assert.match(result.output, /method CTRL_C/);
132
+ assert.match(result.output, /screenPid 111/);
133
+ assert.deepStrictEqual(runner.calls[0], {
134
+ command: 'screen',
135
+ args: ['-S', 'screen-session', '-X', 'stuff', '\x03'],
136
+ });
137
+ });
138
+
139
+ it('should send docker terminate through docker kill', () => {
140
+ const store = createStore();
141
+ store.save(
142
+ createDetachedRecord({
143
+ uuid: 'docker-control-uuid',
144
+ options: {
145
+ isolated: 'docker',
146
+ isolationMode: 'detached',
147
+ sessionName: 'docker-session',
148
+ containerId: 'abc123',
149
+ },
150
+ })
151
+ );
152
+ const runner = createRunner({
153
+ 'docker inspect -f {{.Id}} {{.State.Pid}} docker-session': {
154
+ success: true,
155
+ stdout: 'abcdef 444\n',
156
+ stderr: '',
157
+ status: 0,
158
+ error: null,
159
+ },
160
+ });
161
+
162
+ const result = controlExecution(
163
+ store,
164
+ 'docker-control-uuid',
165
+ ControlAction.TERMINATE,
166
+ runner
167
+ );
168
+
169
+ assert.strictEqual(result.success, true);
170
+ assert.match(result.output, /action terminate/);
171
+ assert.match(result.output, /method SIGKILL/);
172
+ assert.match(result.output, /containerPid 444/);
173
+ assert.deepStrictEqual(runner.calls[0], {
174
+ command: 'docker',
175
+ args: ['kill', 'docker-session'],
176
+ });
177
+ });
178
+
179
+ it('should reject non-detached records', () => {
180
+ const store = createStore();
181
+ store.save(
182
+ createDetachedRecord({
183
+ options: {
184
+ isolated: 'screen',
185
+ isolationMode: 'attached',
186
+ sessionName: 'screen-session',
187
+ },
188
+ })
189
+ );
190
+
191
+ const result = controlExecution(
192
+ store,
193
+ 'screen-session',
194
+ ControlAction.STOP,
195
+ createRunner()
196
+ );
197
+
198
+ assert.strictEqual(result.success, false);
199
+ assert.match(result.error, /Only detached isolated executions/);
200
+ });
201
+ });
202
+
203
+ describe('process id collection', () => {
204
+ it('should parse a GNU Screen process id from screen -ls output', () => {
205
+ const pid = parseScreenPid(
206
+ 'There is a screen on:\n\t1234.my-session\t(Detached)\n',
207
+ 'my-session'
208
+ );
209
+
210
+ assert.strictEqual(pid, 1234);
211
+ });
212
+
213
+ it('should collect wrapper, screen, and descendant process IDs', () => {
214
+ const runner = createRunner({
215
+ 'screen -ls': {
216
+ success: true,
217
+ stdout: '\t111.screen-session\t(Detached)\n',
218
+ stderr: '',
219
+ status: 0,
220
+ error: null,
221
+ },
222
+ 'pgrep -P 111': {
223
+ success: true,
224
+ stdout: '222\n333\n',
225
+ stderr: '',
226
+ status: 0,
227
+ error: null,
228
+ },
229
+ 'pgrep -P 222': {
230
+ success: true,
231
+ stdout: '',
232
+ stderr: '',
233
+ status: 1,
234
+ error: null,
235
+ },
236
+ 'pgrep -P 333': {
237
+ success: true,
238
+ stdout: '',
239
+ stderr: '',
240
+ status: 1,
241
+ error: null,
242
+ },
243
+ });
244
+
245
+ const processIds = collectProcessIds(createDetachedRecord(), runner);
246
+
247
+ assert.deepStrictEqual(processIds, {
248
+ wrapperPid: 12345,
249
+ screenPid: 111,
250
+ commandPids: [222, 333],
251
+ });
252
+ });
253
+ });
@@ -241,29 +241,116 @@ describe('Isolation Resource Cleanup Verification', () => {
241
241
  // Use the canRunLinuxDockerImages function from isolation module
242
242
  // to properly detect if Linux containers can run (handles Windows containers mode)
243
243
  const { canRunLinuxDockerImages } = require('../src/lib/isolation');
244
+ const DOCKER_TEST_TIMEOUT = process.platform === 'win32' ? 30000 : 20000;
245
+ const DOCKER_STATE_WAIT_TIMEOUT =
246
+ process.platform === 'win32' ? 20000 : 10000;
247
+
248
+ it(
249
+ 'should show docker container as exited after command completes (auto-exit by default)',
250
+ { timeout: DOCKER_TEST_TIMEOUT },
251
+ async () => {
252
+ if (!canRunLinuxDockerImages()) {
253
+ console.log(
254
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
255
+ );
256
+ return;
257
+ }
244
258
 
245
- it('should show docker container as exited after command completes (auto-exit by default)', async () => {
246
- if (!canRunLinuxDockerImages()) {
247
- console.log(
248
- ' Skipping: docker not available, daemon not running, or Linux containers not supported'
259
+ const containerName = `test-cleanup-docker-${Date.now()}`;
260
+
261
+ // Run a quick command in detached mode
262
+ const result = await runInDocker('echo "test" && sleep 0.1', {
263
+ image: 'alpine:latest',
264
+ session: containerName,
265
+ detached: true,
266
+ keepAlive: false,
267
+ });
268
+
269
+ assert.strictEqual(result.success, true);
270
+
271
+ // Wait for the container to exit
272
+ const containerExited = await waitFor(() => {
273
+ try {
274
+ const status = execSync(
275
+ `docker inspect -f '{{.State.Status}}' ${containerName}`,
276
+ {
277
+ encoding: 'utf8',
278
+ stdio: ['pipe', 'pipe', 'pipe'],
279
+ }
280
+ ).trim();
281
+ return status === 'exited';
282
+ } catch {
283
+ return false;
284
+ }
285
+ }, DOCKER_STATE_WAIT_TIMEOUT);
286
+
287
+ assert.ok(
288
+ containerExited,
289
+ 'Docker container should be in exited state after command completes (auto-exit by default)'
249
290
  );
250
- return;
291
+
292
+ // Verify with docker ps -a that container is exited (not running)
293
+ try {
294
+ const allContainers = execSync('docker ps -a', {
295
+ encoding: 'utf8',
296
+ stdio: ['pipe', 'pipe', 'pipe'],
297
+ });
298
+ assert.ok(
299
+ allContainers.includes(containerName),
300
+ 'Container should appear in docker ps -a'
301
+ );
302
+
303
+ const runningContainers = execSync('docker ps', {
304
+ encoding: 'utf8',
305
+ stdio: ['pipe', 'pipe', 'pipe'],
306
+ });
307
+ assert.ok(
308
+ !runningContainers.includes(containerName),
309
+ 'Container should NOT appear in docker ps (not running)'
310
+ );
311
+ console.log(
312
+ ' ✓ Docker container auto-exited and stopped (resources released, filesystem preserved)'
313
+ );
314
+ } catch (err) {
315
+ assert.fail(`Failed to verify container status: ${err.message}`);
316
+ }
317
+
318
+ // Clean up
319
+ try {
320
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
321
+ } catch {
322
+ // Ignore cleanup errors
323
+ }
251
324
  }
325
+ );
326
+
327
+ it(
328
+ 'should keep docker container running when keepAlive is true',
329
+ { timeout: DOCKER_TEST_TIMEOUT },
330
+ async () => {
331
+ if (!canRunLinuxDockerImages()) {
332
+ console.log(
333
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
334
+ );
335
+ return;
336
+ }
252
337
 
253
- const containerName = `test-cleanup-docker-${Date.now()}`;
338
+ const containerName = `test-keepalive-docker-${Date.now()}`;
254
339
 
255
- // Run a quick command in detached mode
256
- const result = await runInDocker('echo "test" && sleep 0.1', {
257
- image: 'alpine:latest',
258
- session: containerName,
259
- detached: true,
260
- keepAlive: false,
261
- });
340
+ // Run command with keepAlive enabled
341
+ const result = await runInDocker('echo "test"', {
342
+ image: 'alpine:latest',
343
+ session: containerName,
344
+ detached: true,
345
+ keepAlive: true,
346
+ });
262
347
 
263
- assert.strictEqual(result.success, true);
348
+ assert.strictEqual(result.success, true);
349
+
350
+ // Wait a bit for the command to complete
351
+ await new Promise((resolve) => setTimeout(resolve, 1000));
264
352
 
265
- // Wait for the container to exit
266
- const containerExited = await waitFor(() => {
353
+ // Container should still be running
267
354
  try {
268
355
  const status = execSync(
269
356
  `docker inspect -f '{{.State.Status}}' ${containerName}`,
@@ -272,101 +359,25 @@ describe('Isolation Resource Cleanup Verification', () => {
272
359
  stdio: ['pipe', 'pipe', 'pipe'],
273
360
  }
274
361
  ).trim();
275
- return status === 'exited';
276
- } catch {
277
- return false;
362
+ assert.strictEqual(
363
+ status,
364
+ 'running',
365
+ 'Container should still be running with keepAlive=true'
366
+ );
367
+ console.log(
368
+ ' ✓ Docker container kept running as expected with --keep-alive'
369
+ );
370
+ } catch (err) {
371
+ assert.fail(`Failed to verify container is running: ${err.message}`);
278
372
  }
279
- }, 10000);
280
-
281
- assert.ok(
282
- containerExited,
283
- 'Docker container should be in exited state after command completes (auto-exit by default)'
284
- );
285
-
286
- // Verify with docker ps -a that container is exited (not running)
287
- try {
288
- const allContainers = execSync('docker ps -a', {
289
- encoding: 'utf8',
290
- stdio: ['pipe', 'pipe', 'pipe'],
291
- });
292
- assert.ok(
293
- allContainers.includes(containerName),
294
- 'Container should appear in docker ps -a'
295
- );
296
-
297
- const runningContainers = execSync('docker ps', {
298
- encoding: 'utf8',
299
- stdio: ['pipe', 'pipe', 'pipe'],
300
- });
301
- assert.ok(
302
- !runningContainers.includes(containerName),
303
- 'Container should NOT appear in docker ps (not running)'
304
- );
305
- console.log(
306
- ' ✓ Docker container auto-exited and stopped (resources released, filesystem preserved)'
307
- );
308
- } catch (err) {
309
- assert.fail(`Failed to verify container status: ${err.message}`);
310
- }
311
-
312
- // Clean up
313
- try {
314
- execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
315
- } catch {
316
- // Ignore cleanup errors
317
- }
318
- });
319
-
320
- it('should keep docker container running when keepAlive is true', async () => {
321
- if (!canRunLinuxDockerImages()) {
322
- console.log(
323
- ' Skipping: docker not available, daemon not running, or Linux containers not supported'
324
- );
325
- return;
326
- }
327
-
328
- const containerName = `test-keepalive-docker-${Date.now()}`;
329
-
330
- // Run command with keepAlive enabled
331
- const result = await runInDocker('echo "test"', {
332
- image: 'alpine:latest',
333
- session: containerName,
334
- detached: true,
335
- keepAlive: true,
336
- });
337
-
338
- assert.strictEqual(result.success, true);
339
-
340
- // Wait a bit for the command to complete
341
- await new Promise((resolve) => setTimeout(resolve, 1000));
342
373
 
343
- // Container should still be running
344
- try {
345
- const status = execSync(
346
- `docker inspect -f '{{.State.Status}}' ${containerName}`,
347
- {
348
- encoding: 'utf8',
349
- stdio: ['pipe', 'pipe', 'pipe'],
350
- }
351
- ).trim();
352
- assert.strictEqual(
353
- status,
354
- 'running',
355
- 'Container should still be running with keepAlive=true'
356
- );
357
- console.log(
358
- ' ✓ Docker container kept running as expected with --keep-alive'
359
- );
360
- } catch (err) {
361
- assert.fail(`Failed to verify container is running: ${err.message}`);
362
- }
363
-
364
- // Clean up
365
- try {
366
- execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
367
- } catch {
368
- // Ignore cleanup errors
374
+ // Clean up
375
+ try {
376
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
377
+ } catch {
378
+ // Ignore cleanup errors
379
+ }
369
380
  }
370
- });
381
+ );
371
382
  });
372
383
  });
@@ -7,6 +7,7 @@
7
7
 
8
8
  const { describe, it } = require('node:test');
9
9
  const assert = require('assert');
10
+ const path = require('path');
10
11
  const {
11
12
  isCommandAvailable,
12
13
  hasTTY,
@@ -525,7 +526,7 @@ describe('Isolation Runner with Available Backends', () => {
525
526
  } = require('../src/lib/isolation');
526
527
  const { execSync } = require('child_process');
527
528
 
528
- // Screen integration tests moved to screen-integration.test.js
529
+ // Screen integration tests moved to screen-integration.js
529
530
  // to keep file under the 1000-line limit.
530
531
 
531
532
  describe('runInTmux (if available)', () => {
@@ -668,8 +669,9 @@ describe('detectShellInEnvironment', () => {
668
669
  { image: 'alpine:latest' },
669
670
  'auto'
670
671
  );
672
+ const shellName = path.basename(result);
671
673
  assert.ok(
672
- ['bash', 'zsh', 'sh'].includes(result),
674
+ ['bash', 'zsh', 'sh'].includes(shellName),
673
675
  `Expected a valid shell (bash/zsh/sh), got: ${result}`
674
676
  );
675
677
  console.log(` Detected shell in alpine:latest: ${result}`);
@@ -0,0 +1,154 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { mergeChangesetsIn } from '../../scripts/merge-changesets.mjs';
15
+
16
+ function makeTempPackage(packageName) {
17
+ const dir = mkdtempSync(join(tmpdir(), 'merge-changesets-'));
18
+ writeFileSync(
19
+ join(dir, 'package.json'),
20
+ JSON.stringify({ name: packageName, version: '0.0.0' })
21
+ );
22
+ mkdirSync(join(dir, '.changeset'));
23
+ return dir;
24
+ }
25
+
26
+ function writeChangeset(dir, fileName, body) {
27
+ writeFileSync(join(dir, '.changeset', fileName), body);
28
+ }
29
+
30
+ describe('mergeChangesetsIn', () => {
31
+ let tmpDir;
32
+
33
+ beforeEach(() => {
34
+ tmpDir = makeTempPackage('start-command');
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (tmpDir && existsSync(tmpDir)) {
39
+ rmSync(tmpDir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ it('does nothing with zero changesets', () => {
44
+ const result = mergeChangesetsIn(tmpDir);
45
+ expect(result.merged).toBe(false);
46
+ expect(readdirSync(join(tmpDir, '.changeset'))).toEqual([]);
47
+ });
48
+
49
+ it('does nothing with a single changeset', () => {
50
+ writeChangeset(
51
+ tmpDir,
52
+ 'lone.md',
53
+ `---\n'start-command': patch\n---\n\nOnly one\n`
54
+ );
55
+ const result = mergeChangesetsIn(tmpDir);
56
+ expect(result.merged).toBe(false);
57
+ expect(readdirSync(join(tmpDir, '.changeset'))).toEqual(['lone.md']);
58
+ });
59
+
60
+ it('merges multiple changesets into a single file with the highest bump', () => {
61
+ writeChangeset(
62
+ tmpDir,
63
+ 'a.md',
64
+ `---\n'start-command': patch\n---\n\nFix A\n`
65
+ );
66
+ writeChangeset(
67
+ tmpDir,
68
+ 'b.md',
69
+ `---\n'start-command': minor\n---\n\nFeature B\n`
70
+ );
71
+
72
+ const result = mergeChangesetsIn(tmpDir);
73
+ expect(result.merged).toBe(true);
74
+ expect(result.bumpType).toBe('minor');
75
+
76
+ const files = readdirSync(join(tmpDir, '.changeset'));
77
+ expect(files.length).toBe(1);
78
+ const mergedFile = files[0];
79
+ expect(mergedFile.startsWith('merged-')).toBe(true);
80
+
81
+ const content = readFileSync(
82
+ join(tmpDir, '.changeset', mergedFile),
83
+ 'utf8'
84
+ );
85
+ expect(content).toContain("'start-command': minor");
86
+ expect(content).toContain('Fix A');
87
+ expect(content).toContain('Feature B');
88
+ });
89
+
90
+ it('chooses major over minor and patch', () => {
91
+ writeChangeset(tmpDir, 'a.md', `---\n'start-command': patch\n---\n\nFix\n`);
92
+ writeChangeset(
93
+ tmpDir,
94
+ 'b.md',
95
+ `---\n'start-command': major\n---\n\nBreaking\n`
96
+ );
97
+ writeChangeset(
98
+ tmpDir,
99
+ 'c.md',
100
+ `---\n'start-command': minor\n---\n\nFeat\n`
101
+ );
102
+
103
+ const result = mergeChangesetsIn(tmpDir);
104
+ expect(result.bumpType).toBe('major');
105
+ });
106
+
107
+ it('reads package name from package.json (no hardcoded placeholder)', () => {
108
+ const otherDir = mkdtempSync(join(tmpdir(), 'merge-changesets-other-'));
109
+ try {
110
+ writeFileSync(
111
+ join(otherDir, 'package.json'),
112
+ JSON.stringify({ name: 'some-other-pkg', version: '1.0.0' })
113
+ );
114
+ mkdirSync(join(otherDir, '.changeset'));
115
+ writeFileSync(
116
+ join(otherDir, '.changeset', 'a.md'),
117
+ `---\n'some-other-pkg': minor\n---\n\nA\n`
118
+ );
119
+ writeFileSync(
120
+ join(otherDir, '.changeset', 'b.md'),
121
+ `---\n'some-other-pkg': patch\n---\n\nB\n`
122
+ );
123
+
124
+ const result = mergeChangesetsIn(otherDir);
125
+ expect(result.merged).toBe(true);
126
+
127
+ const files = readdirSync(join(otherDir, '.changeset'));
128
+ const merged = readFileSync(
129
+ join(otherDir, '.changeset', files[0]),
130
+ 'utf8'
131
+ );
132
+ expect(merged).toContain("'some-other-pkg': minor");
133
+ } finally {
134
+ rmSync(otherDir, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ it('throws when .changeset directory is missing', () => {
139
+ const noChangesetDir = mkdtempSync(
140
+ join(tmpdir(), 'merge-changesets-empty-')
141
+ );
142
+ try {
143
+ writeFileSync(
144
+ join(noChangesetDir, 'package.json'),
145
+ JSON.stringify({ name: 'x', version: '0.0.0' })
146
+ );
147
+ expect(() => mergeChangesetsIn(noChangesetDir)).toThrow(
148
+ /Changeset directory not found/
149
+ );
150
+ } finally {
151
+ rmSync(noChangesetDir, { recursive: true, force: true });
152
+ }
153
+ });
154
+ });