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.
- package/CHANGELOG.md +17 -1
- package/README.md +1 -1
- package/bunfig.toml +2 -2
- package/eslint.config.mjs +1 -1
- package/package.json +2 -2
- package/src/bin/cli.js +30 -0
- package/src/lib/args-parser.js +72 -6
- package/src/lib/execution-control.js +317 -0
- package/src/lib/isolation.js +22 -4
- package/src/lib/status-formatter.js +46 -2
- package/src/lib/usage.js +5 -1
- package/test/args-parser-control.js +71 -0
- package/test/{args-parser.test.js → args-parser.js} +1 -1
- package/test/cli.js +260 -0
- package/test/create-github-release.mjs +118 -0
- package/test/docker-autoremove.js +175 -0
- package/test/execution-control.js +253 -0
- package/test/{isolation-cleanup.test.js → isolation-cleanup.js} +120 -109
- package/test/{isolation.test.js → isolation.js} +4 -2
- package/test/merge-changesets.mjs +154 -0
- package/test/publish-to-crates.mjs +194 -0
- package/test/release-name.mjs +117 -0
- package/test/{screen-integration.test.js → screen-integration.js} +1 -1
- package/test/{ssh-integration.test.js → ssh-integration.js} +1 -1
- package/test/{status-query.test.js → status-query.js} +2 -0
- package/test/{substitution.test.js → substitution.js} +1 -2
- package/test/{user-manager.test.js → user-manager.js} +17 -0
- package/test/cli.test.js +0 -218
- package/test/docker-autoremove.test.js +0 -164
- package/test/release-name.test.mjs +0 -34
- /package/test/{args-parser-shell.test.js → args-parser-shell.js} +0 -0
- /package/test/{echo-integration.test.js → echo-integration.js} +0 -0
- /package/test/{execution-store.test.js → execution-store.js} +0 -0
- /package/test/{failure-handler.test.js → failure-handler.js} +0 -0
- /package/test/{isolation-log-utils.test.js → isolation-log-utils.js} +0 -0
- /package/test/{isolation-stacking.test.js → isolation-stacking.js} +0 -0
- /package/test/{output-blocks.test.js → output-blocks.js} +0 -0
- /package/test/{public-exports.test.js → public-exports.js} +0 -0
- /package/test/{regression-84.test.js → regression-84.js} +0 -0
- /package/test/{regression-89.test.js → regression-89.js} +0 -0
- /package/test/{regression-91.test.js → regression-91.js} +0 -0
- /package/test/{sequence-parser.test.js → sequence-parser.js} +0 -0
- /package/test/{session-name-status.test.js → session-name-status.js} +0 -0
- /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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
+
const containerName = `test-keepalive-docker-${Date.now()}`;
|
|
254
339
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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.
|
|
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(
|
|
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
|
+
});
|