start-command 0.35.4 → 0.37.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.
- package/CHANGELOG.md +14 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/bin/cli.js +48 -7
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +32 -1
- package/src/lib/atomic-write.js +34 -0
- package/src/lib/attached-diagnostics.js +90 -0
- package/src/lib/cgroup-memory.js +43 -11
- package/src/lib/command-builder.js +43 -0
- package/src/lib/cpu-penalty-monitor.js +226 -0
- package/src/lib/cpu-penalty.js +175 -0
- package/src/lib/detached-finalize.js +16 -0
- package/src/lib/docker-cleanup.js +35 -7
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- package/src/lib/docker-resource-limits.js +28 -0
- package/src/lib/docker-resource-options.js +180 -0
- package/src/lib/docker-runtime-args.js +23 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +153 -30
- package/src/lib/execution-resume-all.js +11 -0
- package/src/lib/execution-resume.js +314 -54
- package/src/lib/execution-store.js +25 -121
- package/src/lib/exit-evidence.js +123 -0
- package/src/lib/exit-reason.js +24 -6
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +48 -7
- package/src/lib/launch-owner.js +25 -0
- package/src/lib/launch-persistence.js +54 -0
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +18 -6
- package/src/lib/resume-resources.js +88 -0
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/status-formatter.js +28 -0
- package/src/lib/store-lock.js +133 -0
- package/src/lib/usage.js +16 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +10 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +19 -11
- package/test/exit-reason.js +6 -1
- package/test/failure-handler.js +11 -0
- package/test/isolation.js +5 -4
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-174.js +3 -1
- package/test/regression-176.js +7 -0
- package/test/regression-180.js +17 -10
- package/test/regression-182.js +7 -3
- package/test/regression-185.js +4 -9
- package/test/regression-187.js +11 -2
- package/test/regression-189.js +224 -0
- package/test/regression-190.js +111 -0
- package/test/regression-191.js +160 -0
- package/test/regression-192.js +66 -0
- package/test/regression-193.js +251 -0
- package/test/regression-194.js +182 -0
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +54 -0
- package/test/status-query.js +97 -15
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const { resumeExecution } = require('../src/lib/execution-resume');
|
|
8
|
+
const { recoverKilledExecution } = require('../src/lib/execution-recovery');
|
|
9
|
+
const { SessionState } = require('../src/lib/session-probe');
|
|
10
|
+
const {
|
|
11
|
+
buildDetachedDockerCompletionScript,
|
|
12
|
+
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
13
|
+
} = require('../src/lib/docker-cleanup');
|
|
14
|
+
|
|
15
|
+
function fixture(options = {}) {
|
|
16
|
+
let current = {
|
|
17
|
+
uuid: 'issue-198-uuid',
|
|
18
|
+
command: 'original',
|
|
19
|
+
status: 'executed',
|
|
20
|
+
exitCode: 137,
|
|
21
|
+
options: {
|
|
22
|
+
isolated: 'docker',
|
|
23
|
+
isolationMode: 'detached',
|
|
24
|
+
sessionName: 'box',
|
|
25
|
+
containerId: 'same-id',
|
|
26
|
+
...options,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const store = {
|
|
30
|
+
get: () => globalThis.structuredClone(current),
|
|
31
|
+
save: (r) => {
|
|
32
|
+
current = globalThis.structuredClone(r);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
return { store, current: () => current };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mockDocker(calls, scripts = []) {
|
|
39
|
+
return (_bin, args) => {
|
|
40
|
+
calls.push(args);
|
|
41
|
+
if (args[0] === 'cp') {
|
|
42
|
+
scripts.push(fs.readFileSync(args[1], 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
success: true,
|
|
46
|
+
stdout:
|
|
47
|
+
args[0] === 'info'
|
|
48
|
+
? JSON.stringify({ DockerRootDir: '/docker' })
|
|
49
|
+
: args.includes('--size')
|
|
50
|
+
? '1024'
|
|
51
|
+
: args.includes('{{.State.Running}}')
|
|
52
|
+
? 'true'
|
|
53
|
+
: args.includes('{{json .HostConfig}}')
|
|
54
|
+
? '{}'
|
|
55
|
+
: args[0] === 'run'
|
|
56
|
+
? 'new-container-id'
|
|
57
|
+
: '',
|
|
58
|
+
status: 0,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const stopped = () => ({ state: SessionState.STOPPED, alive: false });
|
|
64
|
+
|
|
65
|
+
test('replacement command resumes the same container without a snapshot or changed ID', async () => {
|
|
66
|
+
const { store, current } = fixture({ commandHandoff: true });
|
|
67
|
+
const calls = [],
|
|
68
|
+
scripts = [];
|
|
69
|
+
const result = await resumeExecution(store, 'box', {
|
|
70
|
+
command: "printf '%s' 'new $command'",
|
|
71
|
+
probe: stopped,
|
|
72
|
+
runner: mockDocker(calls, scripts),
|
|
73
|
+
startWatcher: () => true,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(result.success, true, result.error);
|
|
76
|
+
assert.ok(calls.some((args) => args[0] === 'cp'));
|
|
77
|
+
assert.ok(calls.some((args) => args[0] === 'start'));
|
|
78
|
+
assert.ok(
|
|
79
|
+
!calls.some((args) => ['commit', 'run', 'create'].includes(args[0]))
|
|
80
|
+
);
|
|
81
|
+
assert.equal(current().options.containerId, 'same-id');
|
|
82
|
+
assert.equal(current().options.sessionName, 'box');
|
|
83
|
+
assert.ok(scripts[0].includes('new $command'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('automatic recovery replaces a prior explicit handoff and exports its attempt', () => {
|
|
87
|
+
const { store } = fixture({
|
|
88
|
+
commandHandoff: true,
|
|
89
|
+
onKillResume: 1,
|
|
90
|
+
recoveryCommand: 'printf recovered',
|
|
91
|
+
});
|
|
92
|
+
const scripts = [];
|
|
93
|
+
const result = recoverKilledExecution({
|
|
94
|
+
store,
|
|
95
|
+
executionId: 'issue-198-uuid',
|
|
96
|
+
exitCode: 137,
|
|
97
|
+
oomKilled: false,
|
|
98
|
+
runner: mockDocker([], scripts),
|
|
99
|
+
startWatcher: () => true,
|
|
100
|
+
});
|
|
101
|
+
assert.equal(result.recovered, true, result.reason);
|
|
102
|
+
assert.ok(scripts[0].includes('START_COMMAND_RECOVERY_ATTEMPT=1'));
|
|
103
|
+
assert.ok(scripts[0].includes('printf recovered'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('legacy snapshots preflight before commit and remove originals only with non-forced rm after start', async () => {
|
|
107
|
+
const { store, current } = fixture();
|
|
108
|
+
const calls = [];
|
|
109
|
+
const result = await resumeExecution(store, 'box', {
|
|
110
|
+
command: 'new',
|
|
111
|
+
probe: stopped,
|
|
112
|
+
removeOriginal: true,
|
|
113
|
+
runner: mockDocker(calls),
|
|
114
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
115
|
+
startWatcher: () => true,
|
|
116
|
+
});
|
|
117
|
+
assert.equal(result.success, true, result.error);
|
|
118
|
+
assert.ok(
|
|
119
|
+
calls.findIndex((a) => a.includes('--size')) <
|
|
120
|
+
calls.findIndex((a) => a[0] === 'commit')
|
|
121
|
+
);
|
|
122
|
+
assert.ok(
|
|
123
|
+
calls.findIndex((a) => a[0] === 'run') <
|
|
124
|
+
calls.findIndex((a) => a[0] === 'rm')
|
|
125
|
+
);
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
calls.find((a) => a[0] === 'rm'),
|
|
128
|
+
['rm', 'box']
|
|
129
|
+
);
|
|
130
|
+
assert.equal(current().options.commandHandoff, true);
|
|
131
|
+
assert.equal(current().options.snapshotImage, 'start-command-resume/box:1');
|
|
132
|
+
assert.match(result.output, /snapshotting/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('disk shortage rolls back the reservation without committing or removing the original', async () => {
|
|
136
|
+
const { store, current } = fixture();
|
|
137
|
+
const calls = [];
|
|
138
|
+
const result = await resumeExecution(store, 'box', {
|
|
139
|
+
command: 'new',
|
|
140
|
+
probe: stopped,
|
|
141
|
+
removeOriginal: true,
|
|
142
|
+
runner: mockDocker(calls),
|
|
143
|
+
snapshotOptions: { freeBytes: () => 0 },
|
|
144
|
+
startWatcher: () => true,
|
|
145
|
+
});
|
|
146
|
+
assert.equal(result.success, false);
|
|
147
|
+
assert.match(result.error, /Insufficient disk/);
|
|
148
|
+
assert.ok(!calls.some((a) => ['commit', 'rm', 'rmi'].includes(a[0])));
|
|
149
|
+
assert.equal(current().options.sessionName, 'box');
|
|
150
|
+
assert.equal(current().options.launchPending, undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('snapshot failure removes a completed image and restores the original execution', async () => {
|
|
154
|
+
const { store, current } = fixture();
|
|
155
|
+
const calls = [];
|
|
156
|
+
const base = mockDocker(calls);
|
|
157
|
+
const result = await resumeExecution(store, 'box', {
|
|
158
|
+
command: 'new',
|
|
159
|
+
probe: stopped,
|
|
160
|
+
runner: (bin, args) =>
|
|
161
|
+
args[0] === 'run'
|
|
162
|
+
? { success: false, stderr: 'launch failed' }
|
|
163
|
+
: base(bin, args),
|
|
164
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
165
|
+
startWatcher: () => true,
|
|
166
|
+
});
|
|
167
|
+
assert.equal(result.success, false);
|
|
168
|
+
assert.ok(
|
|
169
|
+
calls.some((a) => a[0] === 'rmi' && a[1] === 'start-command-resume/box:1')
|
|
170
|
+
);
|
|
171
|
+
assert.equal(current().options.sessionName, 'box');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('watcher removes its generated image after the cleanup policy removes its container', () => {
|
|
175
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-198-'));
|
|
176
|
+
const watcher = path.join(dir, 'watcher.sh');
|
|
177
|
+
const events = path.join(dir, 'events');
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
watcher,
|
|
181
|
+
`#!/bin/sh
|
|
182
|
+
docker() {
|
|
183
|
+
case "$1" in
|
|
184
|
+
wait) echo 0 ;;
|
|
185
|
+
inspect) case "$3" in
|
|
186
|
+
*State.Running*) echo false ;;
|
|
187
|
+
*State.ExitCode*) echo '0 false 2026-10-10T00:00:00Z 2026-10-10T00:00:01Z' ;;
|
|
188
|
+
*snapshot-image*) echo 'start-command-resume/box:1' ;;
|
|
189
|
+
*) echo '' ;;
|
|
190
|
+
esac ;;
|
|
191
|
+
rm|rmi) printf '%s\\n' "$*" >> events ;;
|
|
192
|
+
esac
|
|
193
|
+
}
|
|
194
|
+
${buildDetachedDockerCompletionScript(
|
|
195
|
+
'box-resume-1',
|
|
196
|
+
DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
|
|
197
|
+
null
|
|
198
|
+
)}
|
|
199
|
+
`
|
|
200
|
+
);
|
|
201
|
+
// Keep POSIX quoting inside a file, out of Windows process argv/PATH.
|
|
202
|
+
const result = spawnSync('sh', ['watcher.sh'], {
|
|
203
|
+
cwd: dir,
|
|
204
|
+
encoding: 'utf8',
|
|
205
|
+
});
|
|
206
|
+
assert.equal(result.status, 0, result.stderr);
|
|
207
|
+
assert.deepEqual(fs.readFileSync(events, 'utf8').trim().split('\n'), [
|
|
208
|
+
'rm -f box-resume-1',
|
|
209
|
+
'rmi start-command-resume/box:1',
|
|
210
|
+
]);
|
|
211
|
+
} finally {
|
|
212
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('changed caller labels use a guarded snapshot and reapply merged labels', async () => {
|
|
217
|
+
const { store, current } = fixture({
|
|
218
|
+
commandHandoff: true,
|
|
219
|
+
labels: ['role=old', 'team=runtime'],
|
|
220
|
+
});
|
|
221
|
+
const calls = [];
|
|
222
|
+
const result = await resumeExecution(store, 'box', {
|
|
223
|
+
probe: stopped,
|
|
224
|
+
resourceOptions: { labels: ['role=new'] },
|
|
225
|
+
runner: mockDocker(calls),
|
|
226
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
227
|
+
startWatcher: () => true,
|
|
228
|
+
});
|
|
229
|
+
assert.equal(result.success, true, result.error);
|
|
230
|
+
const run = calls.find((args) => args[0] === 'run');
|
|
231
|
+
assert.ok(run.includes('role=new'));
|
|
232
|
+
assert.ok(run.includes('team=runtime'));
|
|
233
|
+
assert.ok(!run.includes('role=old'));
|
|
234
|
+
assert.ok(calls.some((args) => args[0] === 'commit'));
|
|
235
|
+
assert.deepEqual(current().options.labels, ['role=new', 'team=runtime']);
|
|
236
|
+
assert.equal(current().options.commandHandoff, true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('original retained by default and when a successor has already stopped', async () => {
|
|
240
|
+
for (const removeOriginal of [false, true]) {
|
|
241
|
+
const { store } = fixture();
|
|
242
|
+
const calls = [];
|
|
243
|
+
const base = mockDocker(calls);
|
|
244
|
+
const result = await resumeExecution(store, 'box', {
|
|
245
|
+
command: 'new',
|
|
246
|
+
probe: stopped,
|
|
247
|
+
removeOriginal,
|
|
248
|
+
runner: (bin, args) =>
|
|
249
|
+
args.includes('{{.State.Running}}')
|
|
250
|
+
? { success: true, stdout: 'false' }
|
|
251
|
+
: base(bin, args),
|
|
252
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
253
|
+
startWatcher: () => true,
|
|
254
|
+
});
|
|
255
|
+
assert.equal(result.success, true, result.error);
|
|
256
|
+
assert.ok(!calls.some((args) => args[0] === 'rm'));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('repeated snapshot successors retain the original root-session attribution', async () => {
|
|
261
|
+
const { store, current } = fixture();
|
|
262
|
+
for (const role of ['first', 'second']) {
|
|
263
|
+
const calls = [];
|
|
264
|
+
const result = await resumeExecution(store, 'box', {
|
|
265
|
+
command: 'new',
|
|
266
|
+
probe: stopped,
|
|
267
|
+
resourceOptions: { labels: [`role=${role}`] },
|
|
268
|
+
runner: mockDocker(calls),
|
|
269
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
270
|
+
startWatcher: () => true,
|
|
271
|
+
});
|
|
272
|
+
assert.equal(result.success, true, result.error);
|
|
273
|
+
assert.ok(
|
|
274
|
+
calls
|
|
275
|
+
.find((args) => args[0] === 'run')
|
|
276
|
+
.includes('start-command.root-session=box')
|
|
277
|
+
);
|
|
278
|
+
assert.equal(current().options.rootSession, 'box');
|
|
279
|
+
}
|
|
280
|
+
assert.equal(current().options.resumeCount, 2);
|
|
281
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const { describe, it, expect } = require('bun:test');
|
|
2
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
3
|
+
const {
|
|
4
|
+
buildDockerRuntimeArgs,
|
|
5
|
+
buildDockerRuntimeMetadata,
|
|
6
|
+
} = require('../src/lib/docker-runtime-args');
|
|
7
|
+
const { buildNextLevelCommand } = require('../src/lib/command-builder');
|
|
8
|
+
|
|
9
|
+
describe('Docker attribution labels (#199)', () => {
|
|
10
|
+
it('attributes replacement containers to their stable root and execution', () => {
|
|
11
|
+
const { dockerLabels } = require('../src/lib/docker-labels');
|
|
12
|
+
expect(
|
|
13
|
+
dockerLabels({ session: 'demo-resume-1', sessionId: 'uuid' })
|
|
14
|
+
).toContain('start-command.root-session=demo-resume-1');
|
|
15
|
+
expect(
|
|
16
|
+
dockerLabels({
|
|
17
|
+
session: 'demo-resume-1-resume-2',
|
|
18
|
+
sessionId: 'uuid',
|
|
19
|
+
resumeCount: 2,
|
|
20
|
+
})
|
|
21
|
+
).toEqual([
|
|
22
|
+
'start-command.session=demo-resume-1-resume-2',
|
|
23
|
+
'start-command.root-session=demo',
|
|
24
|
+
'start-command.resume-count=2',
|
|
25
|
+
'start-command.uuid=uuid',
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
it('parses repeated labels and preserves equals signs and empty values', () => {
|
|
29
|
+
const { wrapperOptions } = parseArgs([
|
|
30
|
+
'-i',
|
|
31
|
+
'docker',
|
|
32
|
+
'--label',
|
|
33
|
+
'hive-mind.tool=codex',
|
|
34
|
+
'--label=task.url=https://github.com/o/r/issues/1?q=a=b',
|
|
35
|
+
'--label',
|
|
36
|
+
'empty=',
|
|
37
|
+
'--',
|
|
38
|
+
'echo',
|
|
39
|
+
]);
|
|
40
|
+
expect(wrapperOptions.labels).toEqual([
|
|
41
|
+
'hive-mind.tool=codex',
|
|
42
|
+
'task.url=https://github.com/o/r/issues/1?q=a=b',
|
|
43
|
+
'empty=',
|
|
44
|
+
]);
|
|
45
|
+
const args = buildDockerRuntimeArgs(wrapperOptions);
|
|
46
|
+
for (const label of wrapperOptions.labels) {
|
|
47
|
+
expect(args).toContain(label);
|
|
48
|
+
}
|
|
49
|
+
expect(buildDockerRuntimeMetadata(wrapperOptions).labels).toEqual(
|
|
50
|
+
wrapperOptions.labels
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
it('rejects malformed, reserved and non-Docker labels', () => {
|
|
54
|
+
for (const label of [
|
|
55
|
+
'missing-value',
|
|
56
|
+
'=empty-key',
|
|
57
|
+
'start-command.uuid=spoof',
|
|
58
|
+
]) {
|
|
59
|
+
expect(() =>
|
|
60
|
+
parseArgs(['-i', 'docker', '--label', label, '--', 'true'])
|
|
61
|
+
).toThrow();
|
|
62
|
+
}
|
|
63
|
+
expect(() => parseArgs(['--label', 'task=one', '--', 'true'])).toThrow();
|
|
64
|
+
});
|
|
65
|
+
it('forwards labels safely through nested Docker isolation', () => {
|
|
66
|
+
const command = buildNextLevelCommand(
|
|
67
|
+
{
|
|
68
|
+
isolatedStack: ['screen', 'docker'],
|
|
69
|
+
labels: ['task=a "quote" $value'],
|
|
70
|
+
},
|
|
71
|
+
'true'
|
|
72
|
+
);
|
|
73
|
+
expect(
|
|
74
|
+
parseArgs(
|
|
75
|
+
require('../src/lib/shell-utils').splitShellWords(command).slice(1)
|
|
76
|
+
).wrapperOptions.labels
|
|
77
|
+
).toEqual(['task=a "quote" $value']);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { describe, it, expect } = require('bun:test');
|
|
2
|
+
const { spawnSync } = require('child_process');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
5
|
+
|
|
6
|
+
describe('wrapper help (#201)', () => {
|
|
7
|
+
for (const flag of ['--help', '-h']) {
|
|
8
|
+
it(`${flag} prints the no-argument usage and exits successfully`, () => {
|
|
9
|
+
const cli = path.join(__dirname, '../src/bin/cli.js');
|
|
10
|
+
const usage = spawnSync(process.execPath, [cli], { encoding: 'utf8' });
|
|
11
|
+
const result = spawnSync(process.execPath, [cli, flag], {
|
|
12
|
+
encoding: 'utf8',
|
|
13
|
+
});
|
|
14
|
+
expect(result.status).toBe(0);
|
|
15
|
+
expect(result.stderr).toBe('');
|
|
16
|
+
expect(result.stdout).toBe(usage.stdout);
|
|
17
|
+
expect(parseArgs([flag]).wrapperOptions.help).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it(`${flag} after the separator remains part of the command`, () => {
|
|
20
|
+
const result = parseArgs(['--', 'grep', flag]);
|
|
21
|
+
expect(result.wrapperOptions.help).toBe(false);
|
|
22
|
+
expect(result.rawCommand).toEqual(['grep', flag]);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const { describe, it, expect } = require('bun:test');
|
|
2
|
+
const { spawnSync } = require('child_process');
|
|
3
|
+
const {
|
|
4
|
+
isInteractiveShellCommand,
|
|
5
|
+
isShellInvocationWithArgs,
|
|
6
|
+
buildShellWithArgsCmdArgs,
|
|
7
|
+
buildDisplayCommand,
|
|
8
|
+
} = require('../src/lib/shell-utils');
|
|
9
|
+
|
|
10
|
+
describe('shell command semantics (#202)', () => {
|
|
11
|
+
for (const operator of [';', '&&', '||', '|', '&', '>', '<', '\n']) {
|
|
12
|
+
it(`keeps the whole command line when it contains ${JSON.stringify(operator)}`, () => {
|
|
13
|
+
const command = `sh -c 'exit 7'${operator} echo after $?`;
|
|
14
|
+
expect(isShellInvocationWithArgs(command)).toBe(false);
|
|
15
|
+
expect(buildDisplayCommand(command)).toBe(command);
|
|
16
|
+
expect(isInteractiveShellCommand(`sh ${operator} echo after`)).toBe(
|
|
17
|
+
false
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
it('preserves the script, empty positional argv, quotes, and quoted operators', () => {
|
|
22
|
+
const command = `sh -c 'printf "%s\\n" "$0" "$1" "$2"; echo "|"' a 'b c' ''`;
|
|
23
|
+
expect(isShellInvocationWithArgs(command)).toBe(true);
|
|
24
|
+
expect(buildShellWithArgsCmdArgs(command)).toEqual([
|
|
25
|
+
'sh',
|
|
26
|
+
'-c',
|
|
27
|
+
'printf "%s\\n" "$0" "$1" "$2"; echo "|"',
|
|
28
|
+
'a',
|
|
29
|
+
'b c',
|
|
30
|
+
'',
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
it('does not classify a missing script or a script-file argument as -c', () => {
|
|
34
|
+
expect(isShellInvocationWithArgs('sh -c')).toBe(false);
|
|
35
|
+
expect(isShellInvocationWithArgs("sh file -c 'echo hi'")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it('retains direct bash -i -c behavior from #91', () => {
|
|
38
|
+
expect(buildShellWithArgsCmdArgs('bash -i -c "nvm --version"')).toEqual([
|
|
39
|
+
'bash',
|
|
40
|
+
'-i',
|
|
41
|
+
'-c',
|
|
42
|
+
'nvm --version',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
it('leaves outer expansions, comments and globbing to the shell', () => {
|
|
46
|
+
for (const command of [
|
|
47
|
+
'sh -c "echo $$"',
|
|
48
|
+
'sh -c echo $HOME',
|
|
49
|
+
'sh -c echo *',
|
|
50
|
+
'sh -c echo # comment',
|
|
51
|
+
'sh -c echo {a,b}',
|
|
52
|
+
"sh -c 'echo hi'\u00a0arg",
|
|
53
|
+
'sh -c echo \\\nhi',
|
|
54
|
+
]) {
|
|
55
|
+
expect(isShellInvocationWithArgs(command)).toBe(false);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
it('preserves literal escapes within a directly executed script', () => {
|
|
59
|
+
expect(buildShellWithArgsCmdArgs('sh -c "printf \\q"')).toEqual([
|
|
60
|
+
'sh',
|
|
61
|
+
'-c',
|
|
62
|
+
'printf \\q',
|
|
63
|
+
]);
|
|
64
|
+
expect(buildShellWithArgsCmdArgs('sh -c "echo \\$0" a')).toEqual([
|
|
65
|
+
'sh',
|
|
66
|
+
'-c',
|
|
67
|
+
'echo $0',
|
|
68
|
+
'a',
|
|
69
|
+
]);
|
|
70
|
+
expect(buildShellWithArgsCmdArgs('sh -c echo a\\;b')).toEqual([
|
|
71
|
+
'sh',
|
|
72
|
+
'-c',
|
|
73
|
+
'echo',
|
|
74
|
+
'a;b',
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
const run = process.platform === 'win32' ? it.skip : it;
|
|
78
|
+
for (const [command, output, status] of [
|
|
79
|
+
["sh -c 'exit 7'; echo after $?", 'after 7', 0],
|
|
80
|
+
["sh -c 'echo $0 $1' a b", 'a b', 0],
|
|
81
|
+
["sh -c 'kill -9 $$'; exit $?", '', 137],
|
|
82
|
+
]) {
|
|
83
|
+
run(`executes ${command} with the expected output and exit`, () => {
|
|
84
|
+
const args = isShellInvocationWithArgs(command)
|
|
85
|
+
? buildShellWithArgsCmdArgs(command)
|
|
86
|
+
: ['sh', '-c', command];
|
|
87
|
+
const result = spawnSync(args[0], args.slice(1), { encoding: 'utf8' });
|
|
88
|
+
expect(result.stdout.trim()).toBe(output);
|
|
89
|
+
expect(result.status).toBe(status);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
package/test/regression-91.js
CHANGED
|
@@ -69,7 +69,7 @@ function buildAttachedCmdArgs(command, shellToUse = '/bin/bash') {
|
|
|
69
69
|
describe('isShellInvocationWithArgs (issue #91)', () => {
|
|
70
70
|
it('should return true for "bash -i -c nvm --version"', () => {
|
|
71
71
|
assert.strictEqual(
|
|
72
|
-
isShellInvocationWithArgs('bash -i -c nvm --version'),
|
|
72
|
+
isShellInvocationWithArgs('bash -i -c "nvm --version"'),
|
|
73
73
|
true
|
|
74
74
|
);
|
|
75
75
|
});
|
|
@@ -79,11 +79,14 @@ describe('isShellInvocationWithArgs (issue #91)', () => {
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
it('should return true for "bash -c echo hello"', () => {
|
|
82
|
-
assert.strictEqual(isShellInvocationWithArgs('bash -c echo hello'), true);
|
|
82
|
+
assert.strictEqual(isShellInvocationWithArgs('bash -c "echo hello"'), true);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
it('should return true for "zsh -c nvm --version"', () => {
|
|
86
|
-
assert.strictEqual(
|
|
86
|
+
assert.strictEqual(
|
|
87
|
+
isShellInvocationWithArgs('zsh -c "nvm --version"'),
|
|
88
|
+
true
|
|
89
|
+
);
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
it('should return true for "sh -c ls"', () => {
|
|
@@ -115,12 +118,12 @@ describe('isShellInvocationWithArgs (issue #91)', () => {
|
|
|
115
118
|
|
|
116
119
|
describe('buildShellWithArgsCmdArgs (issue #91)', () => {
|
|
117
120
|
it('should reconstruct "bash -i -c nvm --version" correctly', () => {
|
|
118
|
-
const result = buildShellWithArgsCmdArgs('bash -i -c nvm --version');
|
|
121
|
+
const result = buildShellWithArgsCmdArgs('bash -i -c "nvm --version"');
|
|
119
122
|
assert.deepStrictEqual(result, ['bash', '-i', '-c', 'nvm --version']);
|
|
120
123
|
});
|
|
121
124
|
|
|
122
125
|
it('should reconstruct "bash -c echo hello" correctly', () => {
|
|
123
|
-
const result = buildShellWithArgsCmdArgs('bash -c echo hello');
|
|
126
|
+
const result = buildShellWithArgsCmdArgs('bash -c "echo hello"');
|
|
124
127
|
assert.deepStrictEqual(result, ['bash', '-c', 'echo hello']);
|
|
125
128
|
});
|
|
126
129
|
|
|
@@ -130,13 +133,13 @@ describe('buildShellWithArgsCmdArgs (issue #91)', () => {
|
|
|
130
133
|
});
|
|
131
134
|
|
|
132
135
|
it('should handle zsh with -c', () => {
|
|
133
|
-
const result = buildShellWithArgsCmdArgs('zsh -c nvm --version');
|
|
136
|
+
const result = buildShellWithArgsCmdArgs('zsh -c "nvm --version"');
|
|
134
137
|
assert.deepStrictEqual(result, ['zsh', '-c', 'nvm --version']);
|
|
135
138
|
});
|
|
136
139
|
|
|
137
140
|
it('should handle /bin/bash -i -c with multi-word script', () => {
|
|
138
141
|
const result = buildShellWithArgsCmdArgs(
|
|
139
|
-
'/bin/bash -i -c node -e process.version'
|
|
142
|
+
'/bin/bash -i -c "node -e process.version"'
|
|
140
143
|
);
|
|
141
144
|
assert.deepStrictEqual(result, [
|
|
142
145
|
'/bin/bash',
|
|
@@ -147,7 +150,7 @@ describe('buildShellWithArgsCmdArgs (issue #91)', () => {
|
|
|
147
150
|
});
|
|
148
151
|
|
|
149
152
|
it('should not include -c argument inside script argument (no double -c)', () => {
|
|
150
|
-
const result = buildShellWithArgsCmdArgs('bash -i -c nvm --version');
|
|
153
|
+
const result = buildShellWithArgsCmdArgs('bash -i -c "nvm --version"');
|
|
151
154
|
// The script arg must be 'nvm --version', not '-c nvm --version'
|
|
152
155
|
assert.strictEqual(result[result.length - 1], 'nvm --version');
|
|
153
156
|
assert.strictEqual(result.indexOf('-c'), 2);
|
|
@@ -159,13 +162,13 @@ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
|
|
|
159
162
|
// Each test verifies that `bash -i -c "cmd"` style commands are NOT re-wrapped
|
|
160
163
|
// in another outer shell -c invocation.
|
|
161
164
|
//
|
|
162
|
-
// Before fix: buildAttachedCmdArgs('bash -i -c nvm --version')
|
|
163
|
-
// → ['/bin/bash', '-i', '-c', 'bash -i -c nvm --version'] (WRONG: double-wrap)
|
|
164
|
-
// After fix: buildAttachedCmdArgs('bash -i -c nvm --version')
|
|
165
|
+
// Before fix: buildAttachedCmdArgs('bash -i -c "nvm --version"')
|
|
166
|
+
// → ['/bin/bash', '-i', '-c', 'bash -i -c "nvm --version"'] (WRONG: double-wrap)
|
|
167
|
+
// After fix: buildAttachedCmdArgs('bash -i -c "nvm --version"')
|
|
165
168
|
// → ['bash', '-i', '-c', 'nvm --version'] (CORRECT: direct pass)
|
|
166
169
|
|
|
167
170
|
it('should pass "bash -i -c nvm --version" directly without outer shell wrapper', () => {
|
|
168
|
-
const args = buildAttachedCmdArgs('bash -i -c nvm --version');
|
|
171
|
+
const args = buildAttachedCmdArgs('bash -i -c "nvm --version"');
|
|
169
172
|
// Must start with 'bash', not with '/bin/bash' (the outer shellToUse)
|
|
170
173
|
assert.strictEqual(args[0], 'bash');
|
|
171
174
|
// Must not wrap in outer bash -i -c
|
|
@@ -182,7 +185,7 @@ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
|
|
|
182
185
|
});
|
|
183
186
|
|
|
184
187
|
it('should pass "zsh -c nvm --version" directly without outer shell wrapper', () => {
|
|
185
|
-
const args = buildAttachedCmdArgs('zsh -c nvm --version', '/bin/zsh');
|
|
188
|
+
const args = buildAttachedCmdArgs('zsh -c "nvm --version"', '/bin/zsh');
|
|
186
189
|
assert.deepStrictEqual(args, ['zsh', '-c', 'nvm --version']);
|
|
187
190
|
assert.ok(
|
|
188
191
|
args.filter((a) => a === '-c').length === 1,
|
|
@@ -191,12 +194,12 @@ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
|
|
|
191
194
|
});
|
|
192
195
|
|
|
193
196
|
it('should pass "bash -c echo hello" directly', () => {
|
|
194
|
-
const args = buildAttachedCmdArgs('bash -c echo hello');
|
|
197
|
+
const args = buildAttachedCmdArgs('bash -c "echo hello"');
|
|
195
198
|
assert.deepStrictEqual(args, ['bash', '-c', 'echo hello']);
|
|
196
199
|
});
|
|
197
200
|
|
|
198
201
|
it('should not introduce a second bash layer (no shell-inside-shell)', () => {
|
|
199
|
-
const args = buildAttachedCmdArgs('bash -i -c nvm --version');
|
|
202
|
+
const args = buildAttachedCmdArgs('bash -i -c "nvm --version"');
|
|
200
203
|
// The first element of the docker image command args must NOT be the outer shell
|
|
201
204
|
// i.e., must not be ['/bin/bash', '-i', '-c', 'bash ...']
|
|
202
205
|
assert.notStrictEqual(
|
|
@@ -206,7 +209,7 @@ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
|
|
|
206
209
|
);
|
|
207
210
|
// Must not contain the original full command string as an argument
|
|
208
211
|
assert.ok(
|
|
209
|
-
!args.includes('bash -i -c nvm --version'),
|
|
212
|
+
!args.includes('bash -i -c "nvm --version"'),
|
|
210
213
|
'Must not contain the full command string as a single argument (double-wrap)'
|
|
211
214
|
);
|
|
212
215
|
});
|
|
@@ -241,7 +244,7 @@ describe('isShellInvocationWithArgs is mutually exclusive with isInteractiveShel
|
|
|
241
244
|
'bash',
|
|
242
245
|
'bash -i',
|
|
243
246
|
'bash --norc',
|
|
244
|
-
'bash -i -c nvm --version',
|
|
247
|
+
'bash -i -c "nvm --version"',
|
|
245
248
|
'bash -c echo hi',
|
|
246
249
|
'zsh -c ls',
|
|
247
250
|
'nvm --version',
|
|
@@ -263,14 +266,14 @@ describe('isShellInvocationWithArgs is mutually exclusive with isInteractiveShel
|
|
|
263
266
|
describe('buildDisplayCommand (issue #91 display fix)', () => {
|
|
264
267
|
it('should quote the -c script argument when it contains spaces', () => {
|
|
265
268
|
assert.strictEqual(
|
|
266
|
-
buildDisplayCommand('bash -i -c nvm --version'),
|
|
269
|
+
buildDisplayCommand('bash -i -c "nvm --version"'),
|
|
267
270
|
'bash -i -c "nvm --version"'
|
|
268
271
|
);
|
|
269
272
|
});
|
|
270
273
|
|
|
271
274
|
it('should quote the -c script argument for plain "bash -c echo hello"', () => {
|
|
272
275
|
assert.strictEqual(
|
|
273
|
-
buildDisplayCommand('bash -c echo hello'),
|
|
276
|
+
buildDisplayCommand('bash -c "echo hello"'),
|
|
274
277
|
'bash -c "echo hello"'
|
|
275
278
|
);
|
|
276
279
|
});
|
|
@@ -289,7 +292,7 @@ describe('buildDisplayCommand (issue #91 display fix)', () => {
|
|
|
289
292
|
|
|
290
293
|
it('should handle zsh -c with spaces', () => {
|
|
291
294
|
assert.strictEqual(
|
|
292
|
-
buildDisplayCommand('zsh -c echo hello world'),
|
|
295
|
+
buildDisplayCommand('zsh -c "echo hello world"'),
|
|
293
296
|
'zsh -c "echo hello world"'
|
|
294
297
|
);
|
|
295
298
|
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const { describe, it } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { readFileSync } = require('node:fs');
|
|
4
|
+
const { resolve } = require('node:path');
|
|
5
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
6
|
+
const { buildDockerRuntimeArgs } = require('../src/lib/docker-runtime-args');
|
|
7
|
+
const { ExecutionRecord } = require('../src/lib/execution-store');
|
|
8
|
+
const { formatRecord } = require('../src/lib/status-formatter');
|
|
9
|
+
const fixtures = JSON.parse(
|
|
10
|
+
readFileSync(resolve(__dirname, '../../parity/fixtures/contracts.json'))
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
describe('shared JavaScript and Rust golden contracts', () => {
|
|
14
|
+
for (const fixture of fixtures['cli-parsing']) {
|
|
15
|
+
it(`CLI parsing: ${fixture.name}`, () => {
|
|
16
|
+
if (fixture.error) {
|
|
17
|
+
assert.throws(() => parseArgs(fixture.argv), new RegExp(fixture.error));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const parsed = parseArgs(fixture.argv);
|
|
21
|
+
assert.equal(parsed.command, fixture.command);
|
|
22
|
+
assert.deepEqual(parsed.rawCommand, fixture.rawCommand);
|
|
23
|
+
for (const [key, value] of Object.entries(fixture.options)) {
|
|
24
|
+
assert.deepEqual(parsed.wrapperOptions[key], value, key);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
for (const fixture of fixtures['isolation-arguments']) {
|
|
29
|
+
it(`isolation argv: ${fixture.name}`, () => {
|
|
30
|
+
assert.deepEqual(buildDockerRuntimeArgs(fixture.options), fixture.argv);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
it('uses the same JSON and text status output', () => {
|
|
34
|
+
const fixture = fixtures['status-output'];
|
|
35
|
+
const record = new ExecutionRecord(fixture.record);
|
|
36
|
+
assert.deepEqual(JSON.parse(formatRecord(record, 'json')), fixture.json);
|
|
37
|
+
assert.equal(formatRecord(record, 'text'), fixture.text);
|
|
38
|
+
});
|
|
39
|
+
for (const fixture of fixtures['execution-record-format']) {
|
|
40
|
+
it(`preserves the complete execution record format (exit ${fixture.exitCode})`, () => {
|
|
41
|
+
assert.deepEqual(ExecutionRecord.fromObject(fixture).toObject(), fixture);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
it('executes the JavaScript source of the generated Rust parity policy', async () => {
|
|
45
|
+
const { minimumRustTestCount } =
|
|
46
|
+
await import('../../scripts/parity-threshold.mjs');
|
|
47
|
+
for (const fixture of fixtures['parity-threshold']) {
|
|
48
|
+
assert.equal(
|
|
49
|
+
minimumRustTestCount(fixture.javascript),
|
|
50
|
+
fixture.minimumRust
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|