start-command 0.32.1 → 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.
- package/CHANGELOG.md +21 -0
- package/README.md +3 -0
- package/eslint.config.mjs +8 -0
- package/package.json +7 -3
- package/src/bin/cli.js +23 -136
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/docker-cleanup.js +80 -5
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +210 -0
- package/src/lib/execution-resume.js +454 -0
- package/src/lib/execution-store.js +47 -4
- package/src/lib/exit-reason.js +234 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +58 -0
- package/src/lib/isolation.js +13 -10
- package/src/lib/query-commands.js +202 -0
- package/src/lib/screen-isolation.js +2 -1
- package/src/lib/session-probe.js +121 -0
- package/src/lib/shell-utils.js +164 -10
- package/src/lib/status-formatter.js +130 -57
- package/src/lib/usage.js +13 -1
- package/src/lib/user-manager.js +18 -1
- package/test/args-parser-attach-resume.js +159 -0
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/check-file-size.js +91 -0
- package/test/ci-workflow-invariants.js +191 -0
- package/test/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +52 -0
- package/test/exit-reason.js +209 -0
- package/test/failure-handler.js +107 -0
- package/test/load-command-stream.mjs +83 -0
- package/test/regression-162.js +320 -0
- package/test/regression-164.js +308 -0
- package/test/regression-165.js +310 -0
- package/test/regression-84.js +8 -3
- package/test/release-name.mjs +57 -0
- package/test/session-probe.js +150 -0
- package/test/user-manager.js +23 -0
|
@@ -76,6 +76,57 @@ function concurrencyKey(body, key) {
|
|
|
76
76
|
return jobKey(body, key, 6);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Split a job body into individual `- ...` step blocks. */
|
|
80
|
+
function parseSteps(body) {
|
|
81
|
+
const lines = body.split('\n');
|
|
82
|
+
const steps = [];
|
|
83
|
+
let current = null;
|
|
84
|
+
let indent = 0;
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const start = line.match(/^(\s*)-\s/);
|
|
87
|
+
if (start && (current === null || start[1].length === indent)) {
|
|
88
|
+
if (current) {
|
|
89
|
+
steps.push(current.join('\n'));
|
|
90
|
+
}
|
|
91
|
+
current = [line];
|
|
92
|
+
indent = start[1].length;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (current) {
|
|
96
|
+
current.push(line);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (current) {
|
|
100
|
+
steps.push(current.join('\n'));
|
|
101
|
+
}
|
|
102
|
+
return steps;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True when line `index` of `workflow` sits inside a `run:` block. */
|
|
106
|
+
function inRunBlock(workflow, index) {
|
|
107
|
+
const lines = workflow.split('\n');
|
|
108
|
+
const line = lines[index];
|
|
109
|
+
if (/^\s*(- )?run:/.test(line)) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
const indent = line.length - line.trimStart().length;
|
|
113
|
+
if (!line.trim()) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
for (let cursor = index - 1; cursor >= 0; cursor--) {
|
|
117
|
+
const previous = lines[cursor];
|
|
118
|
+
if (!previous.trim()) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const previousIndent = previous.length - previous.trimStart().length;
|
|
122
|
+
if (previousIndent >= indent) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
return /^\s*(- )?run:\s*[|>]/.test(previous);
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
79
130
|
const workflows = listWorkflows();
|
|
80
131
|
|
|
81
132
|
describe('CI workflow invariants', () => {
|
|
@@ -257,6 +308,146 @@ describe('CI workflow invariants', () => {
|
|
|
257
308
|
}
|
|
258
309
|
});
|
|
259
310
|
|
|
311
|
+
// --- issue #168: false negatives the pipeline could not see -------------
|
|
312
|
+
|
|
313
|
+
it('lints and audits the workflows themselves', () => {
|
|
314
|
+
assert.ok(
|
|
315
|
+
workflows.includes('workflows.yml'),
|
|
316
|
+
'no workflows.yml: nothing ran actionlint or zizmor over .github/workflows'
|
|
317
|
+
);
|
|
318
|
+
const meta = readWorkflow('workflows.yml');
|
|
319
|
+
assert.match(
|
|
320
|
+
meta,
|
|
321
|
+
/rhysd\/actionlint/,
|
|
322
|
+
'workflows.yml must run actionlint'
|
|
323
|
+
);
|
|
324
|
+
assert.match(meta, /zizmor/, 'workflows.yml must run zizmor');
|
|
325
|
+
for (const config of ['zizmor.yml', 'actionlint.yaml']) {
|
|
326
|
+
assert.doesNotThrow(
|
|
327
|
+
() => readFileSync(join(repoRoot, '.github', config)),
|
|
328
|
+
`missing .github/${config}`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('never interpolates untrusted context into a run: block', () => {
|
|
334
|
+
// actionlint/zizmor call this template-injection: the value is attacker
|
|
335
|
+
// controlled on a fork PR and is pasted into the shell verbatim.
|
|
336
|
+
const untrusted =
|
|
337
|
+
/\$\{\{\s*github\.(head_ref|base_ref|event\.[A-Za-z0-9_.]*(title|body|name|label|ref))/;
|
|
338
|
+
for (const name of workflows) {
|
|
339
|
+
for (const [index, line] of readWorkflow(name).split('\n').entries()) {
|
|
340
|
+
if (line.trimStart().startsWith('#')) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (inRunBlock(readWorkflow(name), index) && untrusted.test(line)) {
|
|
344
|
+
assert.fail(
|
|
345
|
+
`${name}:${index + 1} interpolates untrusted context into run:; pass it through env: instead - ${line.trim()}`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('does not persist credentials on read-only checkouts', () => {
|
|
353
|
+
for (const name of workflows) {
|
|
354
|
+
for (const job of parseJobs(readWorkflow(name))) {
|
|
355
|
+
if (WRITER_JOBS.has(job.name)) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
for (const step of parseSteps(job.body)) {
|
|
359
|
+
if (!/uses:\s*actions\/checkout/.test(step)) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
assert.match(
|
|
363
|
+
step,
|
|
364
|
+
/persist-credentials:\s*false/,
|
|
365
|
+
`${name}: read-only job "${job.name}" checks out with the token left in .git/config`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('pins third-party actions to a commit hash', () => {
|
|
373
|
+
// Trusted first-party namespaces may use a moving tag; anything else is a
|
|
374
|
+
// mutable ref in a job that can hold write credentials.
|
|
375
|
+
const trusted =
|
|
376
|
+
/^(actions|github|docker|astral-sh|lycheeverse|zizmorcore|changesets)\//;
|
|
377
|
+
for (const name of workflows) {
|
|
378
|
+
const workflow = readWorkflow(name);
|
|
379
|
+
for (const match of workflow.matchAll(/uses:\s*(\S+)/g)) {
|
|
380
|
+
const reference = match[1];
|
|
381
|
+
if (reference.startsWith('docker://') || reference.startsWith('./')) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (trusted.test(reference)) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
assert.match(
|
|
388
|
+
reference,
|
|
389
|
+
/@[0-9a-f]{40}$/,
|
|
390
|
+
`${name}: third-party action ${reference} must be pinned to a commit hash`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('audits both dependency graphs for advisories', () => {
|
|
397
|
+
const security = readWorkflow('security.yml');
|
|
398
|
+
const jobs = parseJobs(security).map((job) => job.name);
|
|
399
|
+
for (const job of ['cargo-audit', 'npm-audit']) {
|
|
400
|
+
assert.ok(jobs.includes(job), `security.yml has no ${job} job`);
|
|
401
|
+
}
|
|
402
|
+
assert.match(
|
|
403
|
+
security,
|
|
404
|
+
/cargo audit/,
|
|
405
|
+
'cargo-audit job must run cargo audit'
|
|
406
|
+
);
|
|
407
|
+
assert.match(security, /npm audit/, 'npm-audit job must run npm audit');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('lints the repository-level scripts/ directory', () => {
|
|
411
|
+
// js/eslint.config.mjs has js/ as its base path, so `eslint .` run from js/
|
|
412
|
+
// silently skipped scripts/ - the release automation (issue #168).
|
|
413
|
+
assert.doesNotThrow(
|
|
414
|
+
() => readFileSync(join(repoRoot, 'eslint.config.mjs')),
|
|
415
|
+
'no repository-level eslint.config.mjs: scripts/ would go unlinted'
|
|
416
|
+
);
|
|
417
|
+
const js = readWorkflow('js.yml');
|
|
418
|
+
assert.match(js, /lint:scripts/, 'js.yml must lint scripts/');
|
|
419
|
+
assert.match(
|
|
420
|
+
js,
|
|
421
|
+
/format:check:scripts/,
|
|
422
|
+
'js.yml must format-check scripts/'
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('scopes CodeQL to this project\u2019s own code', () => {
|
|
427
|
+
// Without this, CodeQL extracts the third-party bundles archived under
|
|
428
|
+
// dev/log/ as evidence and fails pull requests on other projects'
|
|
429
|
+
// findings (issue #168: js/redos in the vendored use-m snapshot).
|
|
430
|
+
const security = readWorkflow('security.yml');
|
|
431
|
+
const codeqlJob = parseJobs(security).find((job) => job.name === 'codeql');
|
|
432
|
+
assert.ok(codeqlJob, 'security.yml has no codeql job');
|
|
433
|
+
const configPath = jobKey(codeqlJob.body, 'config-file', 10);
|
|
434
|
+
assert.ok(
|
|
435
|
+
configPath,
|
|
436
|
+
'the CodeQL init step must pass config-file, or archived evidence is analysed'
|
|
437
|
+
);
|
|
438
|
+
const config = readFileSync(join(repoRoot, configPath), 'utf8');
|
|
439
|
+
assert.match(
|
|
440
|
+
config,
|
|
441
|
+
/^paths-ignore:/m,
|
|
442
|
+
`${configPath} has no paths-ignore`
|
|
443
|
+
);
|
|
444
|
+
assert.match(
|
|
445
|
+
config,
|
|
446
|
+
/^\s*-\s*dev\/log\s*$/m,
|
|
447
|
+
`${configPath} must exclude dev/log`
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
260
451
|
it('parses coverage through the tested helper, not an inline grep', () => {
|
|
261
452
|
const js = readWorkflow('js.yml');
|
|
262
453
|
assert.doesNotMatch(
|
package/test/echo-integration.js
CHANGED
|
@@ -19,22 +19,27 @@
|
|
|
19
19
|
|
|
20
20
|
const { describe, it } = require('node:test');
|
|
21
21
|
const assert = require('assert');
|
|
22
|
-
const { execSync } = require('child_process');
|
|
22
|
+
const { execSync, spawnSync } = require('child_process');
|
|
23
23
|
const path = require('path');
|
|
24
24
|
const {
|
|
25
25
|
isCommandAvailable,
|
|
26
26
|
canRunLinuxDockerImages,
|
|
27
27
|
hasTTY,
|
|
28
28
|
} = require('../src/lib/isolation');
|
|
29
|
+
const { toShellWords } = require('../src/lib/shell-utils');
|
|
29
30
|
|
|
30
31
|
// Path to the CLI
|
|
31
32
|
const CLI_PATH = path.join(__dirname, '..', 'src', 'bin', 'cli.js');
|
|
32
33
|
|
|
33
|
-
// Helper function to run the CLI and capture output
|
|
34
|
+
// Helper function to run the CLI and capture output.
|
|
35
|
+
// The arguments are split here instead of being handed to a host shell, so the
|
|
36
|
+
// CLI receives the same argv on every platform (issue #164).
|
|
34
37
|
function runCli(args, options = {}) {
|
|
35
38
|
const timeout = options.timeout || 30000;
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
const result = spawnSync(
|
|
40
|
+
process.execPath,
|
|
41
|
+
['run', CLI_PATH, ...toShellWords(args)],
|
|
42
|
+
{
|
|
38
43
|
encoding: 'utf8',
|
|
39
44
|
timeout,
|
|
40
45
|
env: {
|
|
@@ -43,16 +48,18 @@ function runCli(args, options = {}) {
|
|
|
43
48
|
START_DISABLE_TRACKING: '1',
|
|
44
49
|
},
|
|
45
50
|
maxBuffer: 1024 * 1024, // 1MB
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
output: err.stdout || '',
|
|
52
|
-
stderr: err.stderr || '',
|
|
53
|
-
exitCode: err.status,
|
|
54
|
-
};
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
const output = result.stdout || '';
|
|
54
|
+
if (result.status === 0) {
|
|
55
|
+
return { success: true, output };
|
|
55
56
|
}
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
output,
|
|
60
|
+
stderr: result.stderr || '',
|
|
61
|
+
exitCode: result.status,
|
|
62
|
+
};
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
// Verify output contains expected structure for attached modes (shows finish block)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for --attach (issue #162)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { test, describe } = require('node:test');
|
|
6
|
+
const assert = require('node:assert');
|
|
7
|
+
const {
|
|
8
|
+
buildAttachPlan,
|
|
9
|
+
attachExecution,
|
|
10
|
+
formatAttachResultAsLinksNotation,
|
|
11
|
+
} = require('../src/lib/execution-attach');
|
|
12
|
+
const { SessionState } = require('../src/lib/session-probe');
|
|
13
|
+
|
|
14
|
+
function makeRecord(options = {}, extra = {}) {
|
|
15
|
+
return {
|
|
16
|
+
uuid: '11111111-2222-3333-4444-555555555555',
|
|
17
|
+
status: 'executing',
|
|
18
|
+
logPath: '/tmp/session.log',
|
|
19
|
+
options: { isolationMode: 'detached', ...options },
|
|
20
|
+
...extra,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const runningProbe = (backend, sessionName) => ({
|
|
25
|
+
backend,
|
|
26
|
+
sessionName,
|
|
27
|
+
state: SessionState.RUNNING,
|
|
28
|
+
alive: true,
|
|
29
|
+
containerStatus: backend === 'docker' ? 'running' : null,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('buildAttachPlan', () => {
|
|
33
|
+
test('attaches to a running docker container', () => {
|
|
34
|
+
const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
|
|
35
|
+
const plan = buildAttachPlan(record, {
|
|
36
|
+
probe: runningProbe('docker', 'box'),
|
|
37
|
+
});
|
|
38
|
+
assert.strictEqual(plan.error, undefined);
|
|
39
|
+
assert.strictEqual(plan.command, 'docker');
|
|
40
|
+
assert.deepStrictEqual(plan.args, ['attach', 'box']);
|
|
41
|
+
assert.strictEqual(plan.interactive, true);
|
|
42
|
+
assert.strictEqual(plan.method, 'DOCKER_ATTACH');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('follows docker logs in read-only mode', () => {
|
|
46
|
+
const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
|
|
47
|
+
const plan = buildAttachPlan(record, {
|
|
48
|
+
readOnly: true,
|
|
49
|
+
probe: runningProbe('docker', 'box'),
|
|
50
|
+
});
|
|
51
|
+
assert.deepStrictEqual(plan.args, ['logs', '-f', 'box']);
|
|
52
|
+
assert.strictEqual(plan.interactive, false);
|
|
53
|
+
assert.strictEqual(plan.method, 'DOCKER_LOG_FOLLOW');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('attaches to a running screen session', () => {
|
|
57
|
+
const record = makeRecord({ isolated: 'screen', sessionName: 'work' });
|
|
58
|
+
const plan = buildAttachPlan(record, {
|
|
59
|
+
probe: runningProbe('screen', 'work'),
|
|
60
|
+
});
|
|
61
|
+
assert.strictEqual(plan.command, 'screen');
|
|
62
|
+
assert.deepStrictEqual(plan.args, ['-r', 'work']);
|
|
63
|
+
assert.strictEqual(plan.interactive, true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('follows the stored log for a read-only screen attach', () => {
|
|
67
|
+
const record = makeRecord({ isolated: 'screen', sessionName: 'work' });
|
|
68
|
+
const plan = buildAttachPlan(record, {
|
|
69
|
+
readOnly: true,
|
|
70
|
+
probe: runningProbe('screen', 'work'),
|
|
71
|
+
});
|
|
72
|
+
assert.strictEqual(plan.command, 'tail');
|
|
73
|
+
assert.deepStrictEqual(plan.args, ['-f', '/tmp/session.log']);
|
|
74
|
+
assert.strictEqual(plan.method, 'LOG_FOLLOW');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('uses tmux read-only attach for --read-only', () => {
|
|
78
|
+
const record = makeRecord({ isolated: 'tmux', sessionName: 'work' });
|
|
79
|
+
const plan = buildAttachPlan(record, {
|
|
80
|
+
probe: runningProbe('tmux', 'work'),
|
|
81
|
+
});
|
|
82
|
+
assert.deepStrictEqual(plan.args, ['attach-session', '-t', 'work']);
|
|
83
|
+
const readOnlyPlan = buildAttachPlan(record, {
|
|
84
|
+
readOnly: true,
|
|
85
|
+
probe: runningProbe('tmux', 'work'),
|
|
86
|
+
});
|
|
87
|
+
assert.deepStrictEqual(readOnlyPlan.args, [
|
|
88
|
+
'attach-session',
|
|
89
|
+
'-r',
|
|
90
|
+
'-t',
|
|
91
|
+
'work',
|
|
92
|
+
]);
|
|
93
|
+
assert.strictEqual(readOnlyPlan.interactive, true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('follows the log for ssh sessions', () => {
|
|
97
|
+
const record = makeRecord({ isolated: 'ssh', sessionName: 'remote' });
|
|
98
|
+
const plan = buildAttachPlan(record, {
|
|
99
|
+
probe: {
|
|
100
|
+
backend: 'ssh',
|
|
101
|
+
sessionName: 'remote',
|
|
102
|
+
state: SessionState.UNKNOWN,
|
|
103
|
+
alive: false,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
assert.strictEqual(plan.command, 'tail');
|
|
107
|
+
assert.strictEqual(plan.method, 'LOG_FOLLOW');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('points at --resume when the session is not running', () => {
|
|
111
|
+
const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
|
|
112
|
+
const plan = buildAttachPlan(record, {
|
|
113
|
+
probe: {
|
|
114
|
+
backend: 'docker',
|
|
115
|
+
sessionName: 'box',
|
|
116
|
+
state: SessionState.STOPPED,
|
|
117
|
+
alive: false,
|
|
118
|
+
containerStatus: 'exited',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
assert.match(plan.error, /is not running/);
|
|
122
|
+
assert.match(plan.error, /--resume/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('reports a removed container distinctly', () => {
|
|
126
|
+
const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
|
|
127
|
+
const plan = buildAttachPlan(record, {
|
|
128
|
+
probe: {
|
|
129
|
+
backend: 'docker',
|
|
130
|
+
sessionName: 'box',
|
|
131
|
+
state: SessionState.MISSING,
|
|
132
|
+
alive: false,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
assert.match(plan.error, /no longer exists/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('rejects records without a session name', () => {
|
|
139
|
+
const plan = buildAttachPlan(makeRecord({ isolated: 'docker' }), {
|
|
140
|
+
probe: { state: SessionState.UNKNOWN, alive: false },
|
|
141
|
+
});
|
|
142
|
+
assert.match(plan.error, /does not contain an isolation session name/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('rejects attached (non-detached) executions', () => {
|
|
146
|
+
const record = makeRecord({
|
|
147
|
+
isolated: 'docker',
|
|
148
|
+
sessionName: 'box',
|
|
149
|
+
isolationMode: 'attached',
|
|
150
|
+
});
|
|
151
|
+
const plan = buildAttachPlan(record, {
|
|
152
|
+
probe: runningProbe('docker', 'box'),
|
|
153
|
+
});
|
|
154
|
+
assert.match(plan.error, /Only detached isolated executions/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('attachExecution', () => {
|
|
159
|
+
function makeStore(record) {
|
|
160
|
+
return { get: () => record };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test('reports a missing execution', () => {
|
|
164
|
+
const result = attachExecution({ get: () => null }, 'nope', {});
|
|
165
|
+
assert.strictEqual(result.success, false);
|
|
166
|
+
assert.match(result.error, /No execution found/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('reports disabled tracking', () => {
|
|
170
|
+
const result = attachExecution(null, 'nope', {});
|
|
171
|
+
assert.strictEqual(result.success, false);
|
|
172
|
+
assert.match(result.error, /Execution tracking is disabled/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('runs the interactive attach command', () => {
|
|
176
|
+
const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
|
|
177
|
+
const calls = [];
|
|
178
|
+
const result = attachExecution(makeStore(record), 'box', {
|
|
179
|
+
probe: () => runningProbe('docker', 'box'),
|
|
180
|
+
interactiveRunner: (command, args) => {
|
|
181
|
+
calls.push([command, ...args]);
|
|
182
|
+
return { success: true, status: 0 };
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
assert.strictEqual(result.success, true);
|
|
186
|
+
assert.deepStrictEqual(calls, [['docker', 'attach', 'box']]);
|
|
187
|
+
assert.match(result.output, /attach/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('surfaces plan errors', () => {
|
|
191
|
+
const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
|
|
192
|
+
const result = attachExecution(makeStore(record), 'box', {
|
|
193
|
+
probe: () => ({
|
|
194
|
+
backend: 'docker',
|
|
195
|
+
sessionName: 'box',
|
|
196
|
+
state: SessionState.STOPPED,
|
|
197
|
+
alive: false,
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
assert.strictEqual(result.success, false);
|
|
201
|
+
assert.match(result.error, /--resume/);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('formatAttachResultAsLinksNotation', () => {
|
|
206
|
+
test('emits a nested links-notation block', () => {
|
|
207
|
+
const output = formatAttachResultAsLinksNotation({
|
|
208
|
+
identifier: 'box',
|
|
209
|
+
uuid: 'u-1',
|
|
210
|
+
backend: 'docker',
|
|
211
|
+
sessionName: 'box',
|
|
212
|
+
method: 'DOCKER_ATTACH',
|
|
213
|
+
readOnly: false,
|
|
214
|
+
command: 'docker attach box',
|
|
215
|
+
message: 'Attaching to detached docker container: box',
|
|
216
|
+
});
|
|
217
|
+
assert.match(output, /^executionAttach\n/);
|
|
218
|
+
assert.match(output, /\n {2}method DOCKER_ATTACH\n/);
|
|
219
|
+
assert.match(output, /\n {2}readOnly false\n/);
|
|
220
|
+
});
|
|
221
|
+
});
|