start-command 0.20.4 → 0.22.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.
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Tests for isolation stacking feature (issue #77)
3
+ */
4
+
5
+ const { describe, it, expect } = require('bun:test');
6
+ const {
7
+ parseArgs,
8
+ validateOptions,
9
+ hasStackedIsolation,
10
+ MAX_ISOLATION_DEPTH,
11
+ } = require('../src/lib/args-parser');
12
+
13
+ describe('Isolation Stacking - Args Parser', () => {
14
+ describe('parseArgs with stacked --isolated', () => {
15
+ it('should parse single isolation (backward compatible)', () => {
16
+ const result = parseArgs(['--isolated', 'docker', '--', 'npm', 'test']);
17
+ expect(result.wrapperOptions.isolated).toBe('docker');
18
+ expect(result.wrapperOptions.isolatedStack).toEqual(['docker']);
19
+ expect(result.command).toBe('npm test');
20
+ });
21
+
22
+ it('should parse multi-level isolation', () => {
23
+ const result = parseArgs([
24
+ '--isolated',
25
+ 'screen ssh docker',
26
+ '--endpoint',
27
+ '_ user@host _',
28
+ '--',
29
+ 'npm',
30
+ 'test',
31
+ ]);
32
+ expect(result.wrapperOptions.isolated).toBe('screen');
33
+ expect(result.wrapperOptions.isolatedStack).toEqual([
34
+ 'screen',
35
+ 'ssh',
36
+ 'docker',
37
+ ]);
38
+ });
39
+
40
+ it('should parse 5-level isolation', () => {
41
+ const result = parseArgs([
42
+ '--isolated',
43
+ 'screen ssh tmux ssh docker',
44
+ '--endpoint',
45
+ '_ user@server1 _ user@server2 _',
46
+ '--',
47
+ 'npm',
48
+ 'test',
49
+ ]);
50
+ expect(result.wrapperOptions.isolatedStack).toEqual([
51
+ 'screen',
52
+ 'ssh',
53
+ 'tmux',
54
+ 'ssh',
55
+ 'docker',
56
+ ]);
57
+ expect(result.wrapperOptions.endpointStack).toEqual([
58
+ null,
59
+ 'user@server1',
60
+ null,
61
+ 'user@server2',
62
+ null,
63
+ ]);
64
+ });
65
+
66
+ it('should parse --isolated=value syntax', () => {
67
+ const result = parseArgs(['--isolated=screen tmux', '--', 'ls']);
68
+ expect(result.wrapperOptions.isolatedStack).toEqual(['screen', 'tmux']);
69
+ });
70
+ });
71
+
72
+ describe('parseArgs with stacked --image', () => {
73
+ it('should parse single image (backward compatible)', () => {
74
+ const result = parseArgs([
75
+ '--isolated',
76
+ 'docker',
77
+ '--image',
78
+ 'ubuntu:22.04',
79
+ '--',
80
+ 'bash',
81
+ ]);
82
+ expect(result.wrapperOptions.image).toBe('ubuntu:22.04');
83
+ });
84
+
85
+ it('should parse image sequence with placeholders', () => {
86
+ const result = parseArgs([
87
+ '--isolated',
88
+ 'screen docker',
89
+ '--image',
90
+ '_ ubuntu:22.04',
91
+ '--',
92
+ 'bash',
93
+ ]);
94
+ expect(result.wrapperOptions.imageStack).toEqual([null, 'ubuntu:22.04']);
95
+ });
96
+
97
+ it('should parse --image=value syntax', () => {
98
+ const result = parseArgs([
99
+ '--isolated',
100
+ 'docker',
101
+ '--image=alpine:latest',
102
+ '--',
103
+ 'sh',
104
+ ]);
105
+ expect(result.wrapperOptions.image).toBe('alpine:latest');
106
+ });
107
+ });
108
+
109
+ describe('parseArgs with stacked --endpoint', () => {
110
+ it('should parse single endpoint (backward compatible)', () => {
111
+ const result = parseArgs([
112
+ '--isolated',
113
+ 'ssh',
114
+ '--endpoint',
115
+ 'user@host',
116
+ '--',
117
+ 'ls',
118
+ ]);
119
+ expect(result.wrapperOptions.endpoint).toBe('user@host');
120
+ });
121
+
122
+ it('should parse endpoint sequence with placeholders', () => {
123
+ const result = parseArgs([
124
+ '--isolated',
125
+ 'screen ssh ssh docker',
126
+ '--endpoint',
127
+ '_ user@host1 user@host2 _',
128
+ '--',
129
+ 'bash',
130
+ ]);
131
+ expect(result.wrapperOptions.endpointStack).toEqual([
132
+ null,
133
+ 'user@host1',
134
+ 'user@host2',
135
+ null,
136
+ ]);
137
+ });
138
+ });
139
+
140
+ describe('hasStackedIsolation', () => {
141
+ it('should return true for multi-level', () => {
142
+ const { wrapperOptions } = parseArgs([
143
+ '--isolated',
144
+ 'screen docker',
145
+ '--',
146
+ 'test',
147
+ ]);
148
+ expect(hasStackedIsolation(wrapperOptions)).toBe(true);
149
+ });
150
+
151
+ it('should return false for single level', () => {
152
+ const { wrapperOptions } = parseArgs([
153
+ '--isolated',
154
+ 'docker',
155
+ '--',
156
+ 'test',
157
+ ]);
158
+ expect(hasStackedIsolation(wrapperOptions)).toBe(false);
159
+ });
160
+
161
+ it('should return false for no isolation', () => {
162
+ const { wrapperOptions } = parseArgs(['echo', 'hello']);
163
+ // When no isolation, isolatedStack is null, so hasStackedIsolation returns falsy
164
+ expect(hasStackedIsolation(wrapperOptions)).toBeFalsy();
165
+ });
166
+ });
167
+ });
168
+
169
+ describe('Isolation Stacking - Validation', () => {
170
+ describe('validateOptions', () => {
171
+ it('should validate single backend (backward compatible)', () => {
172
+ const options = {
173
+ isolated: 'docker',
174
+ isolatedStack: ['docker'],
175
+ };
176
+ expect(() => validateOptions(options)).not.toThrow();
177
+ // Should apply default image
178
+ expect(options.imageStack[0]).toBeDefined();
179
+ });
180
+
181
+ it('should validate multi-level stack', () => {
182
+ const options = {
183
+ isolated: 'screen',
184
+ isolatedStack: ['screen', 'ssh', 'docker'],
185
+ endpointStack: [null, 'user@host', null],
186
+ };
187
+ expect(() => validateOptions(options)).not.toThrow();
188
+ });
189
+
190
+ it('should throw on invalid backend in stack', () => {
191
+ const options = {
192
+ isolated: 'screen',
193
+ isolatedStack: ['screen', 'invalid', 'docker'],
194
+ };
195
+ expect(() => validateOptions(options)).toThrow(/Invalid isolation/);
196
+ });
197
+
198
+ it('should throw on missing SSH endpoint', () => {
199
+ const options = {
200
+ isolated: 'ssh',
201
+ isolatedStack: ['ssh'],
202
+ endpointStack: [null],
203
+ };
204
+ expect(() => validateOptions(options)).toThrow(/requires --endpoint/);
205
+ });
206
+
207
+ it('should throw on image/stack length mismatch', () => {
208
+ const options = {
209
+ isolated: 'screen',
210
+ isolatedStack: ['screen', 'ssh', 'docker'],
211
+ imageStack: [null, 'ubuntu:22.04'], // Only 2, should be 3
212
+ endpointStack: [null, 'user@host', null],
213
+ };
214
+ expect(() => validateOptions(options)).toThrow(/value\(s\)/);
215
+ });
216
+
217
+ it('should throw on depth exceeding limit', () => {
218
+ const tooDeep = Array(MAX_ISOLATION_DEPTH + 1).fill('screen');
219
+ const options = {
220
+ isolated: 'screen',
221
+ isolatedStack: tooDeep,
222
+ };
223
+ expect(() => validateOptions(options)).toThrow(/too deep/);
224
+ });
225
+
226
+ it('should distribute single image to all levels', () => {
227
+ const options = {
228
+ isolated: 'screen',
229
+ isolatedStack: ['screen', 'docker', 'docker'],
230
+ image: 'ubuntu:22.04',
231
+ };
232
+ validateOptions(options);
233
+ expect(options.imageStack).toEqual([
234
+ 'ubuntu:22.04',
235
+ 'ubuntu:22.04',
236
+ 'ubuntu:22.04',
237
+ ]);
238
+ });
239
+
240
+ it('should apply default docker image for each docker level', () => {
241
+ const options = {
242
+ isolated: 'docker',
243
+ isolatedStack: ['docker'],
244
+ };
245
+ validateOptions(options);
246
+ expect(options.imageStack[0]).toBeDefined();
247
+ expect(options.imageStack[0]).toContain(':'); // Should have image:tag format
248
+ });
249
+
250
+ it('should throw if image provided but no docker in stack', () => {
251
+ const options = {
252
+ isolated: 'screen',
253
+ isolatedStack: ['screen', 'tmux'],
254
+ image: 'ubuntu:22.04',
255
+ };
256
+ expect(() => validateOptions(options)).toThrow(/docker/);
257
+ });
258
+
259
+ it('should throw if endpoint provided but no ssh in stack', () => {
260
+ const options = {
261
+ isolated: 'screen',
262
+ isolatedStack: ['screen', 'docker'],
263
+ endpoint: 'user@host',
264
+ imageStack: [null, 'ubuntu:22.04'],
265
+ };
266
+ expect(() => validateOptions(options)).toThrow(/ssh/);
267
+ });
268
+ });
269
+ });
270
+
271
+ describe('Isolation Stacking - Backward Compatibility', () => {
272
+ it('should work with existing docker command', () => {
273
+ const result = parseArgs([
274
+ '--isolated',
275
+ 'docker',
276
+ '--image',
277
+ 'node:18',
278
+ '--',
279
+ 'npm',
280
+ 'test',
281
+ ]);
282
+ expect(result.wrapperOptions.isolated).toBe('docker');
283
+ expect(result.wrapperOptions.image).toBe('node:18');
284
+ expect(result.wrapperOptions.isolatedStack).toEqual(['docker']);
285
+ expect(result.command).toBe('npm test');
286
+ });
287
+
288
+ it('should work with existing ssh command', () => {
289
+ const result = parseArgs([
290
+ '--isolated',
291
+ 'ssh',
292
+ '--endpoint',
293
+ 'user@server',
294
+ '--',
295
+ 'ls',
296
+ '-la',
297
+ ]);
298
+ expect(result.wrapperOptions.isolated).toBe('ssh');
299
+ expect(result.wrapperOptions.endpoint).toBe('user@server');
300
+ expect(result.wrapperOptions.isolatedStack).toEqual(['ssh']);
301
+ });
302
+
303
+ it('should work with existing screen command', () => {
304
+ const result = parseArgs([
305
+ '--isolated',
306
+ 'screen',
307
+ '--detached',
308
+ '--keep-alive',
309
+ '--',
310
+ 'long-running-task',
311
+ ]);
312
+ expect(result.wrapperOptions.isolated).toBe('screen');
313
+ expect(result.wrapperOptions.detached).toBe(true);
314
+ expect(result.wrapperOptions.keepAlive).toBe(true);
315
+ expect(result.wrapperOptions.isolatedStack).toEqual(['screen']);
316
+ });
317
+
318
+ it('should work with -i shorthand', () => {
319
+ const result = parseArgs(['-i', 'tmux', '--', 'vim']);
320
+ expect(result.wrapperOptions.isolated).toBe('tmux');
321
+ expect(result.wrapperOptions.isolatedStack).toEqual(['tmux']);
322
+ });
323
+
324
+ it('should work with attached mode', () => {
325
+ const result = parseArgs([
326
+ '--isolated',
327
+ 'docker',
328
+ '--attached',
329
+ '--image',
330
+ 'alpine',
331
+ '--',
332
+ 'sh',
333
+ ]);
334
+ expect(result.wrapperOptions.attached).toBe(true);
335
+ expect(result.wrapperOptions.isolated).toBe('docker');
336
+ });
337
+
338
+ it('should work with session name', () => {
339
+ const result = parseArgs([
340
+ '--isolated',
341
+ 'screen',
342
+ '--session',
343
+ 'my-session',
344
+ '--',
345
+ 'bash',
346
+ ]);
347
+ expect(result.wrapperOptions.session).toBe('my-session');
348
+ });
349
+
350
+ it('should work with session-id', () => {
351
+ const result = parseArgs([
352
+ '--isolated',
353
+ 'docker',
354
+ '--image',
355
+ 'alpine',
356
+ '--session-id',
357
+ '12345678-1234-4123-8123-123456789012',
358
+ '--',
359
+ 'echo',
360
+ 'hi',
361
+ ]);
362
+ expect(result.wrapperOptions.sessionId).toBe(
363
+ '12345678-1234-4123-8123-123456789012'
364
+ );
365
+ });
366
+ });
@@ -10,6 +10,7 @@ const assert = require('assert');
10
10
  const {
11
11
  isCommandAvailable,
12
12
  hasTTY,
13
+ detectShellInEnvironment,
13
14
  getScreenVersion,
14
15
  supportsLogfileOption,
15
16
  resetScreenVersionCache,
@@ -761,3 +762,66 @@ describe('Default Docker Image Detection', () => {
761
762
  });
762
763
  });
763
764
  });
765
+
766
+ describe('detectShellInEnvironment', () => {
767
+ it('should return the forced shell when shellPreference is not auto', () => {
768
+ const result = detectShellInEnvironment(
769
+ 'docker',
770
+ { image: 'alpine:latest' },
771
+ 'bash'
772
+ );
773
+ assert.strictEqual(result, 'bash');
774
+ });
775
+
776
+ it('should return zsh when shellPreference is zsh', () => {
777
+ const result = detectShellInEnvironment(
778
+ 'ssh',
779
+ { endpoint: 'user@host' },
780
+ 'zsh'
781
+ );
782
+ assert.strictEqual(result, 'zsh');
783
+ });
784
+
785
+ it('should return sh when shellPreference is sh', () => {
786
+ const result = detectShellInEnvironment(
787
+ 'docker',
788
+ { image: 'alpine:latest' },
789
+ 'sh'
790
+ );
791
+ assert.strictEqual(result, 'sh');
792
+ });
793
+
794
+ it('should return sh fallback when docker image is not provided', () => {
795
+ const result = detectShellInEnvironment('docker', {}, 'auto');
796
+ assert.strictEqual(result, 'sh');
797
+ });
798
+
799
+ it('should return sh fallback when ssh endpoint is not provided', () => {
800
+ const result = detectShellInEnvironment('ssh', {}, 'auto');
801
+ assert.strictEqual(result, 'sh');
802
+ });
803
+
804
+ it('should return sh fallback for unknown environment', () => {
805
+ const result = detectShellInEnvironment('screen', {}, 'auto');
806
+ assert.strictEqual(result, 'sh');
807
+ });
808
+
809
+ it('should auto-detect shell in docker if docker is available', () => {
810
+ if (!isCommandAvailable('docker')) {
811
+ console.log(' Skipping: docker not installed');
812
+ return;
813
+ }
814
+ // Use alpine:latest which is commonly available and has sh
815
+ // This test just verifies we get a valid shell back
816
+ const result = detectShellInEnvironment(
817
+ 'docker',
818
+ { image: 'alpine:latest' },
819
+ 'auto'
820
+ );
821
+ assert.ok(
822
+ ['bash', 'zsh', 'sh'].includes(result),
823
+ `Expected a valid shell (bash/zsh/sh), got: ${result}`
824
+ );
825
+ console.log(` Detected shell in alpine:latest: ${result}`);
826
+ });
827
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Tests for sequence-parser.js
3
+ * Isolation stacking feature for issue #77
4
+ */
5
+
6
+ const { describe, it, expect } = require('bun:test');
7
+ const {
8
+ parseSequence,
9
+ formatSequence,
10
+ shiftSequence,
11
+ isSequence,
12
+ distributeOption,
13
+ getValueAtLevel,
14
+ formatIsolationChain,
15
+ buildNextLevelOptions,
16
+ } = require('../src/lib/sequence-parser');
17
+
18
+ describe('Sequence Parser', () => {
19
+ describe('parseSequence', () => {
20
+ it('should parse single value', () => {
21
+ expect(parseSequence('docker')).toEqual(['docker']);
22
+ });
23
+
24
+ it('should parse space-separated sequence', () => {
25
+ expect(parseSequence('screen ssh docker')).toEqual([
26
+ 'screen',
27
+ 'ssh',
28
+ 'docker',
29
+ ]);
30
+ });
31
+
32
+ it('should parse sequence with underscores as null', () => {
33
+ expect(parseSequence('_ ssh _ docker')).toEqual([
34
+ null,
35
+ 'ssh',
36
+ null,
37
+ 'docker',
38
+ ]);
39
+ });
40
+
41
+ it('should handle all underscores', () => {
42
+ expect(parseSequence('_ _ _')).toEqual([null, null, null]);
43
+ });
44
+
45
+ it('should handle empty string', () => {
46
+ expect(parseSequence('')).toEqual([]);
47
+ });
48
+
49
+ it('should handle null/undefined', () => {
50
+ expect(parseSequence(null)).toEqual([]);
51
+ expect(parseSequence(undefined)).toEqual([]);
52
+ });
53
+
54
+ it('should trim whitespace', () => {
55
+ expect(parseSequence(' screen ssh ')).toEqual(['screen', 'ssh']);
56
+ });
57
+
58
+ it('should handle multiple spaces between values', () => {
59
+ expect(parseSequence('screen ssh docker')).toEqual([
60
+ 'screen',
61
+ 'ssh',
62
+ 'docker',
63
+ ]);
64
+ });
65
+ });
66
+
67
+ describe('formatSequence', () => {
68
+ it('should format array with values', () => {
69
+ expect(formatSequence(['screen', 'ssh', 'docker'])).toBe(
70
+ 'screen ssh docker'
71
+ );
72
+ });
73
+
74
+ it('should format array with nulls as underscores', () => {
75
+ expect(formatSequence([null, 'ssh', null, 'docker'])).toBe(
76
+ '_ ssh _ docker'
77
+ );
78
+ });
79
+
80
+ it('should handle empty array', () => {
81
+ expect(formatSequence([])).toBe('');
82
+ });
83
+
84
+ it('should handle non-array', () => {
85
+ expect(formatSequence(null)).toBe('');
86
+ expect(formatSequence(undefined)).toBe('');
87
+ });
88
+ });
89
+
90
+ describe('shiftSequence', () => {
91
+ it('should remove first element', () => {
92
+ expect(shiftSequence(['screen', 'ssh', 'docker'])).toEqual([
93
+ 'ssh',
94
+ 'docker',
95
+ ]);
96
+ });
97
+
98
+ it('should handle nulls', () => {
99
+ expect(shiftSequence([null, 'ssh', null])).toEqual(['ssh', null]);
100
+ });
101
+
102
+ it('should handle single element', () => {
103
+ expect(shiftSequence(['docker'])).toEqual([]);
104
+ });
105
+
106
+ it('should handle empty array', () => {
107
+ expect(shiftSequence([])).toEqual([]);
108
+ });
109
+ });
110
+
111
+ describe('isSequence', () => {
112
+ it('should return true for space-separated values', () => {
113
+ expect(isSequence('screen ssh docker')).toBe(true);
114
+ });
115
+
116
+ it('should return false for single value', () => {
117
+ expect(isSequence('docker')).toBe(false);
118
+ });
119
+
120
+ it('should return false for non-string', () => {
121
+ expect(isSequence(null)).toBe(false);
122
+ expect(isSequence(undefined)).toBe(false);
123
+ expect(isSequence(123)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('distributeOption', () => {
128
+ it('should replicate single value for all levels', () => {
129
+ expect(distributeOption('ubuntu:22.04', 3, '--image')).toEqual([
130
+ 'ubuntu:22.04',
131
+ 'ubuntu:22.04',
132
+ 'ubuntu:22.04',
133
+ ]);
134
+ });
135
+
136
+ it('should parse sequence with matching length', () => {
137
+ expect(distributeOption('_ _ ubuntu:22.04', 3, '--image')).toEqual([
138
+ null,
139
+ null,
140
+ 'ubuntu:22.04',
141
+ ]);
142
+ });
143
+
144
+ it('should throw on length mismatch', () => {
145
+ expect(() => distributeOption('_ _', 3, '--image')).toThrow();
146
+ });
147
+
148
+ it('should handle null/undefined value', () => {
149
+ expect(distributeOption(null, 3, '--image')).toEqual([null, null, null]);
150
+ });
151
+ });
152
+
153
+ describe('getValueAtLevel', () => {
154
+ it('should get value at valid index', () => {
155
+ expect(getValueAtLevel(['a', 'b', 'c'], 1)).toBe('b');
156
+ });
157
+
158
+ it('should handle nulls', () => {
159
+ expect(getValueAtLevel([null, 'b', null], 0)).toBe(null);
160
+ expect(getValueAtLevel([null, 'b', null], 1)).toBe('b');
161
+ });
162
+
163
+ it('should return null for out of bounds', () => {
164
+ expect(getValueAtLevel(['a', 'b'], 5)).toBe(null);
165
+ expect(getValueAtLevel(['a', 'b'], -1)).toBe(null);
166
+ });
167
+
168
+ it('should handle non-array', () => {
169
+ expect(getValueAtLevel(null, 0)).toBe(null);
170
+ });
171
+ });
172
+
173
+ describe('formatIsolationChain', () => {
174
+ it('should format simple chain', () => {
175
+ expect(formatIsolationChain(['screen', 'tmux', 'docker'])).toBe(
176
+ 'screen → tmux → docker'
177
+ );
178
+ });
179
+
180
+ it('should add SSH endpoint', () => {
181
+ const options = { endpointStack: [null, 'user@host', null] };
182
+ expect(formatIsolationChain(['screen', 'ssh', 'docker'], options)).toBe(
183
+ 'screen → ssh@user@host → docker'
184
+ );
185
+ });
186
+
187
+ it('should add Docker image short name', () => {
188
+ const options = { imageStack: [null, null, 'oven/bun:latest'] };
189
+ expect(formatIsolationChain(['screen', 'ssh', 'docker'], options)).toBe(
190
+ 'screen → ssh → docker:bun'
191
+ );
192
+ });
193
+
194
+ it('should handle placeholders', () => {
195
+ expect(formatIsolationChain([null, 'ssh', null])).toBe('_ → ssh → _');
196
+ });
197
+
198
+ it('should handle empty array', () => {
199
+ expect(formatIsolationChain([])).toBe('');
200
+ });
201
+ });
202
+
203
+ describe('buildNextLevelOptions', () => {
204
+ it('should shift all stacks', () => {
205
+ const options = {
206
+ isolated: 'screen',
207
+ isolatedStack: ['screen', 'ssh', 'docker'],
208
+ image: null,
209
+ imageStack: [null, null, 'ubuntu:22.04'],
210
+ endpoint: null,
211
+ endpointStack: [null, 'user@host', null],
212
+ };
213
+
214
+ const next = buildNextLevelOptions(options);
215
+
216
+ expect(next.isolated).toBe('ssh');
217
+ expect(next.isolatedStack).toEqual(['ssh', 'docker']);
218
+ expect(next.image).toBe(null);
219
+ expect(next.imageStack).toEqual([null, 'ubuntu:22.04']);
220
+ expect(next.endpoint).toBe('user@host');
221
+ expect(next.endpointStack).toEqual(['user@host', null]);
222
+ });
223
+
224
+ it('should handle last level', () => {
225
+ const options = {
226
+ isolated: 'docker',
227
+ isolatedStack: ['docker'],
228
+ imageStack: ['ubuntu:22.04'],
229
+ };
230
+
231
+ const next = buildNextLevelOptions(options);
232
+
233
+ expect(next.isolated).toBe(null);
234
+ expect(next.isolatedStack).toEqual([]);
235
+ });
236
+ });
237
+ });
@@ -217,7 +217,7 @@ describe('args-parser user isolation options', () => {
217
217
  'npm',
218
218
  'test',
219
219
  ]);
220
- }, /--isolated-user is not supported with Docker isolation/);
220
+ }, /--isolated-user is not supported with Docker as the first isolation level/);
221
221
  });
222
222
 
223
223
  it('should validate custom username format', () => {