start-command 0.32.0 → 0.33.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +23 -136
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/docker-cleanup.js +80 -5
  9. package/src/lib/execution-attach.js +245 -0
  10. package/src/lib/execution-resume-all.js +210 -0
  11. package/src/lib/execution-resume.js +454 -0
  12. package/src/lib/execution-store.js +47 -4
  13. package/src/lib/exit-reason.js +234 -0
  14. package/src/lib/failure-handler.js +43 -41
  15. package/src/lib/isolation-log-utils.js +58 -0
  16. package/src/lib/isolation.js +13 -10
  17. package/src/lib/query-commands.js +202 -0
  18. package/src/lib/screen-isolation.js +2 -1
  19. package/src/lib/session-probe.js +121 -0
  20. package/src/lib/shell-utils.js +164 -10
  21. package/src/lib/status-formatter.js +130 -57
  22. package/src/lib/usage.js +13 -1
  23. package/src/lib/user-manager.js +18 -1
  24. package/test/args-parser-attach-resume.js +159 -0
  25. package/test/args-parser-control.js +3 -3
  26. package/test/args-parser.js +1 -1
  27. package/test/check-file-size.js +91 -0
  28. package/test/ci-workflow-invariants.js +191 -0
  29. package/test/docker-network-integration.js +22 -14
  30. package/test/echo-integration.js +20 -13
  31. package/test/execution-attach.js +221 -0
  32. package/test/execution-resume.js +433 -0
  33. package/test/execution-store.js +52 -0
  34. package/test/exit-reason.js +209 -0
  35. package/test/failure-handler.js +107 -0
  36. package/test/load-command-stream.mjs +83 -0
  37. package/test/regression-162.js +320 -0
  38. package/test/regression-164.js +308 -0
  39. package/test/regression-165.js +310 -0
  40. package/test/regression-84.js +8 -3
  41. package/test/release-name.mjs +57 -0
  42. package/test/session-probe.js +150 -0
  43. package/test/user-manager.js +23 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Tests for session liveness probing (issue #162)
3
+ */
4
+
5
+ const { test, describe } = require('node:test');
6
+ const assert = require('node:assert');
7
+ const {
8
+ SessionState,
9
+ mapDockerStatusToState,
10
+ parseScreenSessionState,
11
+ probeSession,
12
+ } = require('../src/lib/session-probe');
13
+
14
+ function makeRecord(options) {
15
+ return { uuid: 'u-1', options: { isolationMode: 'detached', ...options } };
16
+ }
17
+
18
+ function makeRunner(responses) {
19
+ return (command, args) => {
20
+ const key = [command, ...args].join(' ');
21
+ const response = responses[key];
22
+ if (!response) {
23
+ return { success: false, stdout: '', stderr: '', status: 1, error: null };
24
+ }
25
+ return {
26
+ success: true,
27
+ stdout: '',
28
+ stderr: '',
29
+ status: 0,
30
+ error: null,
31
+ ...response,
32
+ };
33
+ };
34
+ }
35
+
36
+ describe('parseScreenSessionState', () => {
37
+ const listing = [
38
+ 'There are screens on:',
39
+ '\t12345.other-session\t(Detached)',
40
+ '\t67890.my-session\t(Detached)',
41
+ ].join('\n');
42
+
43
+ test('finds a detached session', () => {
44
+ assert.strictEqual(
45
+ parseScreenSessionState(listing, 'my-session'),
46
+ SessionState.RUNNING
47
+ );
48
+ });
49
+
50
+ test('reports missing sessions', () => {
51
+ assert.strictEqual(
52
+ parseScreenSessionState(listing, 'gone'),
53
+ SessionState.MISSING
54
+ );
55
+ });
56
+
57
+ test('does not match a partial session name', () => {
58
+ assert.strictEqual(
59
+ parseScreenSessionState(listing, 'session'),
60
+ SessionState.MISSING
61
+ );
62
+ });
63
+ });
64
+
65
+ describe('mapDockerStatusToState', () => {
66
+ test('maps running to RUNNING', () => {
67
+ assert.strictEqual(mapDockerStatusToState('running'), SessionState.RUNNING);
68
+ });
69
+
70
+ test('maps exited and created to STOPPED', () => {
71
+ assert.strictEqual(mapDockerStatusToState('exited'), SessionState.STOPPED);
72
+ assert.strictEqual(mapDockerStatusToState('created'), SessionState.STOPPED);
73
+ assert.strictEqual(mapDockerStatusToState('dead'), SessionState.STOPPED);
74
+ });
75
+
76
+ test('maps an unknown or empty status to UNKNOWN', () => {
77
+ assert.strictEqual(mapDockerStatusToState(''), SessionState.UNKNOWN);
78
+ assert.strictEqual(mapDockerStatusToState(null), SessionState.UNKNOWN);
79
+ });
80
+ });
81
+
82
+ describe('probeSession', () => {
83
+ test('detects a running docker container', () => {
84
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
85
+ const runner = makeRunner({
86
+ 'docker inspect -f {{.State.Status}} box': { stdout: 'running\n' },
87
+ });
88
+ const probe = probeSession(record, runner);
89
+ assert.strictEqual(probe.state, SessionState.RUNNING);
90
+ assert.strictEqual(probe.alive, true);
91
+ assert.strictEqual(probe.containerStatus, 'running');
92
+ });
93
+
94
+ test('detects a stopped docker container', () => {
95
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
96
+ const runner = makeRunner({
97
+ 'docker inspect -f {{.State.Status}} box': { stdout: 'exited\n' },
98
+ });
99
+ const probe = probeSession(record, runner);
100
+ assert.strictEqual(probe.state, SessionState.STOPPED);
101
+ assert.strictEqual(probe.alive, false);
102
+ });
103
+
104
+ test('detects a removed docker container', () => {
105
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
106
+ const probe = probeSession(record, makeRunner({}));
107
+ assert.strictEqual(probe.state, SessionState.MISSING);
108
+ assert.strictEqual(probe.alive, false);
109
+ });
110
+
111
+ test('detects a live tmux session', () => {
112
+ const record = makeRecord({ isolated: 'tmux', sessionName: 'work' });
113
+ const runner = makeRunner({ 'tmux has-session -t work': {} });
114
+ assert.strictEqual(
115
+ probeSession(record, runner).state,
116
+ SessionState.RUNNING
117
+ );
118
+ });
119
+
120
+ test('detects a dead tmux session', () => {
121
+ const record = makeRecord({ isolated: 'tmux', sessionName: 'work' });
122
+ assert.strictEqual(
123
+ probeSession(record, makeRunner({})).state,
124
+ SessionState.MISSING
125
+ );
126
+ });
127
+
128
+ test('detects a live screen session', () => {
129
+ const record = makeRecord({ isolated: 'screen', sessionName: 'work' });
130
+ const runner = makeRunner({
131
+ 'screen -ls': { stdout: '\t4242.work\t(Detached)\n', success: false },
132
+ });
133
+ assert.strictEqual(
134
+ probeSession(record, runner).state,
135
+ SessionState.RUNNING
136
+ );
137
+ });
138
+
139
+ test('reports ssh sessions as unknown', () => {
140
+ const record = makeRecord({ isolated: 'ssh', sessionName: 'remote' });
141
+ const probe = probeSession(record, makeRunner({}));
142
+ assert.strictEqual(probe.state, SessionState.UNKNOWN);
143
+ assert.strictEqual(probe.alive, false);
144
+ });
145
+
146
+ test('reports records without a session name as unknown', () => {
147
+ const probe = probeSession({ uuid: 'u', options: {} }, makeRunner({}));
148
+ assert.strictEqual(probe.state, SessionState.UNKNOWN);
149
+ });
150
+ });
@@ -125,6 +125,29 @@ describe('user-manager', () => {
125
125
  assert.ok(name.length <= 31);
126
126
  });
127
127
 
128
+ it('should draw the suffix from a CSPRNG, not Math.random', () => {
129
+ // The 4-character suffix is the only unguessable part of the account
130
+ // name; Math.random made it predictable (CodeQL js/insecure-randomness,
131
+ // issue #168). With Math.random pinned, names minted inside a single
132
+ // millisecond were byte-identical; a CSPRNG keeps them apart.
133
+ const originalRandom = Math.random;
134
+ Math.random = () => 0.5;
135
+ try {
136
+ const suffixes = new Set();
137
+ for (let index = 0; index < 200; index++) {
138
+ const name = generateIsolatedUsername();
139
+ assert.match(name, /^start-[0-9a-z]+$/);
140
+ suffixes.add(name.slice(-4));
141
+ }
142
+ assert.ok(
143
+ suffixes.size > 50,
144
+ `suffix does not vary while Math.random is pinned (${suffixes.size} distinct)`
145
+ );
146
+ } finally {
147
+ Math.random = originalRandom;
148
+ }
149
+ });
150
+
128
151
  it('should handle long prefix by truncating', () => {
129
152
  const longPrefix = 'this-is-a-very-long-prefix';
130
153
  const name = generateIsolatedUsername(longPrefix);