badgr-cli 1.0.48 → 1.1.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/README.md +38 -0
- package/package.json +1 -1
- package/src/api.js +16 -2
- package/src/artifactDownload.js +55 -0
- package/src/badgr.js +104 -0
- package/src/batch.js +22 -4
- package/src/browser.js +23 -0
- package/src/commands/artifacts.js +75 -0
- package/src/commands/batch.js +221 -28
- package/src/commands/billing.js +1 -12
- package/src/commands/capacity.js +9 -4
- package/src/commands/comfyui.js +3 -3
- package/src/commands/connect.js +83 -0
- package/src/commands/doctor.js +127 -0
- package/src/commands/down.js +29 -6
- package/src/commands/launch.js +431 -0
- package/src/commands/pull.js +137 -0
- package/src/commands/run.js +253 -37
- package/src/commands/sbatch.js +232 -0
- package/src/commands/serve.js +3 -3
- package/src/commands/status.js +12 -4
- package/src/commands/task.js +25 -0
- package/src/commands/test-run.js +4 -2
- package/src/credentials.js +65 -0
- package/src/fallback.js +7 -2
- package/src/fanout.js +70 -0
- package/src/gpuDoctor/diskInfo.js +42 -0
- package/src/gpuDoctor/doctor.js +451 -0
- package/src/gpuDoctor/gpuInfo.js +70 -0
- package/src/gpuDoctor/healthCheck.js +63 -0
- package/src/gpuDoctor/logClassifier.js +138 -0
- package/src/gpuDoctor/modelFit.js +107 -0
- package/src/gpuDoctor/probeCache.js +38 -0
- package/src/gpuDoctor/redact.js +29 -0
- package/src/gpuDoctor/torchInfo.js +61 -0
- package/src/gpuDoctor/workflowDoctor.js +96 -0
- package/src/onboarding.js +124 -0
- package/src/slurm.js +193 -0
- package/src/spec.js +59 -2
- package/src/store.js +16 -0
- package/tests/agent-images.test.js +17 -0
- package/tests/artifactDownload.test.js +113 -0
- package/tests/artifacts.test.js +168 -0
- package/tests/batch.test.js +312 -0
- package/tests/browser.test.js +51 -0
- package/tests/capacity.test.js +68 -0
- package/tests/commands.test.js +44 -0
- package/tests/connect.test.js +83 -0
- package/tests/down.test.js +23 -1
- package/tests/fallback-timeout.test.js +41 -0
- package/tests/fanout.test.js +124 -0
- package/tests/gpu-doctor-classifiers.test.js +402 -0
- package/tests/gpu-doctor-doctor.test.js +304 -0
- package/tests/gpu-doctor-probe-cache.test.js +110 -0
- package/tests/gpu-doctor-probes.test.js +257 -0
- package/tests/launch-command-argv.test.js +93 -0
- package/tests/launch-readiness.test.js +1 -0
- package/tests/launch.test.js +440 -0
- package/tests/onboarding.test.js +134 -0
- package/tests/pull.test.js +266 -0
- package/tests/run-lifecycle.test.js +405 -6
- package/tests/sbatch.test.js +190 -0
- package/tests/secrets.test.js +16 -0
- package/tests/slurm.test.js +77 -0
- package/tests/spec.test.js +59 -1
- package/tests/status.test.js +73 -0
- package/tests/task.test.js +109 -0
- package/tests/template.test.js +7 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mockCallApi = vi.fn();
|
|
4
|
+
const mockOpenBrowser = vi.fn();
|
|
5
|
+
const mockSaveConfig = vi.fn(updates => ({ apiKey: 'test-key', baseUrl: 'https://example.test/v1', ...updates }));
|
|
6
|
+
|
|
7
|
+
vi.mock('../src/api.js', () => ({ callApi: (...args) => mockCallApi(...args) }));
|
|
8
|
+
vi.mock('../src/browser.js', () => ({ openBrowser: (...args) => mockOpenBrowser(...args) }));
|
|
9
|
+
vi.mock('../src/config.js', async () => {
|
|
10
|
+
const actual = await vi.importActual('../src/config.js');
|
|
11
|
+
return { ...actual, saveConfig: (...args) => mockSaveConfig(...args) };
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const { ensureBadgrReady } = await import('../src/onboarding.js');
|
|
15
|
+
|
|
16
|
+
const chalk = { red: s => s, dim: s => s, green: s => s, bold: s => s, yellow: s => s, cyan: s => s };
|
|
17
|
+
|
|
18
|
+
describe('ensureBadgrReady', () => {
|
|
19
|
+
let originalStdinTTY;
|
|
20
|
+
let originalStdoutTTY;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
mockCallApi.mockReset();
|
|
24
|
+
mockOpenBrowser.mockReset();
|
|
25
|
+
mockSaveConfig.mockClear();
|
|
26
|
+
process.exitCode = undefined;
|
|
27
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
28
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
29
|
+
originalStdinTTY = process.stdin.isTTY;
|
|
30
|
+
originalStdoutTTY = process.stdout.isTTY;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
process.stdin.isTTY = originalStdinTTY;
|
|
37
|
+
process.stdout.isTTY = originalStdoutTTY;
|
|
38
|
+
process.exitCode = undefined;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('non-interactive (no TTY)', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
process.stdin.isTTY = false;
|
|
44
|
+
process.stdout.isTTY = false;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns the config unchanged when an API key is already stored, without any network calls', async () => {
|
|
48
|
+
const config = { apiKey: 'existing-key', baseUrl: 'https://example.test/v1' };
|
|
49
|
+
const result = await ensureBadgrReady(config, chalk);
|
|
50
|
+
expect(result).toBe(config);
|
|
51
|
+
expect(mockCallApi).not.toHaveBeenCalled();
|
|
52
|
+
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws the standard "Run: badgr login" error instead of opening a browser', async () => {
|
|
56
|
+
const config = { apiKey: undefined, baseUrl: 'https://example.test/v1' };
|
|
57
|
+
await expect(ensureBadgrReady(config, chalk)).rejects.toThrow(/badgr login/);
|
|
58
|
+
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('interactive (TTY present)', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
process.stdin.isTTY = true;
|
|
65
|
+
process.stdout.isTTY = true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skips the login/billing flow entirely when already logged in with a positive balance', async () => {
|
|
69
|
+
const config = { apiKey: 'existing-key', baseUrl: 'https://example.test/v1' };
|
|
70
|
+
mockCallApi.mockResolvedValueOnce({ credits: 10 }); // /api/me
|
|
71
|
+
const result = await ensureBadgrReady(config, chalk);
|
|
72
|
+
expect(result.apiKey).toBe('existing-key');
|
|
73
|
+
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('opens the login URL, polls until complete, and stores the returned API key', async () => {
|
|
77
|
+
vi.useFakeTimers();
|
|
78
|
+
const config = { apiKey: undefined, baseUrl: 'https://example.test/v1' };
|
|
79
|
+
|
|
80
|
+
mockCallApi
|
|
81
|
+
.mockResolvedValueOnce({ session_id: 'sess-1', login_url: 'https://aibadgr.com/login?cli_session=sess-1' }) // POST /cli/session
|
|
82
|
+
.mockResolvedValueOnce({ status: 'pending' }) // first poll
|
|
83
|
+
.mockResolvedValueOnce({ status: 'complete', api_key: 'new-key-from-browser', credits: 10 }) // second poll
|
|
84
|
+
.mockResolvedValueOnce({ credits: 10 }); // /api/me funded check afterward
|
|
85
|
+
|
|
86
|
+
const promise = ensureBadgrReady(config, chalk);
|
|
87
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
88
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
89
|
+
const result = await promise;
|
|
90
|
+
|
|
91
|
+
expect(mockOpenBrowser).toHaveBeenCalledWith('https://aibadgr.com/login?cli_session=sess-1');
|
|
92
|
+
expect(mockSaveConfig).toHaveBeenCalledWith({ apiKey: 'new-key-from-browser' });
|
|
93
|
+
expect(result.apiKey).toBe('new-key-from-browser');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws if the login session expires before completion', async () => {
|
|
97
|
+
vi.useFakeTimers();
|
|
98
|
+
const config = { apiKey: undefined, baseUrl: 'https://example.test/v1' };
|
|
99
|
+
mockCallApi
|
|
100
|
+
.mockResolvedValueOnce({ session_id: 'sess-1', login_url: 'https://aibadgr.com/login?cli_session=sess-1' })
|
|
101
|
+
.mockResolvedValueOnce({ status: 'expired' });
|
|
102
|
+
|
|
103
|
+
const promise = ensureBadgrReady(config, chalk);
|
|
104
|
+
const assertion = expect(promise).rejects.toThrow(/expired/);
|
|
105
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
106
|
+
await assertion;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('opens billing checkout and polls until funded when balance is $0', async () => {
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
const config = { apiKey: 'existing-key', baseUrl: 'https://example.test/v1' };
|
|
112
|
+
|
|
113
|
+
mockCallApi
|
|
114
|
+
.mockResolvedValueOnce({ credits: 0 }) // initial /api/me
|
|
115
|
+
.mockResolvedValueOnce({ credits: 0 }) // first poll
|
|
116
|
+
.mockResolvedValueOnce({ credits: 10 }); // second poll — funded
|
|
117
|
+
|
|
118
|
+
const promise = ensureBadgrReady(config, chalk);
|
|
119
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
120
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
121
|
+
await promise;
|
|
122
|
+
|
|
123
|
+
expect(mockOpenBrowser).toHaveBeenCalledWith('https://aibadgr.com/dashboard#billing');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('does not block the launch if the balance check itself fails to reach the API', async () => {
|
|
127
|
+
const config = { apiKey: 'existing-key', baseUrl: 'https://example.test/v1' };
|
|
128
|
+
mockCallApi.mockRejectedValueOnce(new Error('network down'));
|
|
129
|
+
const result = await ensureBadgrReady(config, chalk);
|
|
130
|
+
expect(result.apiKey).toBe('existing-key');
|
|
131
|
+
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { changedFilesFromPatch, intersectFiles, parsePullArgs, localChangedFiles } from '../src/commands/pull.js';
|
|
4
|
+
|
|
5
|
+
describe('localChangedFiles', () => {
|
|
6
|
+
function fakeGit(stdout) {
|
|
7
|
+
return () => ({ status: 0, stdout, stderr: '' });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
it('parses an unstaged modification (leading-space status code)', () => {
|
|
11
|
+
// Regression: a naive .trim() on the line eats this leading status
|
|
12
|
+
// space and shifts the fixed-width slice, corrupting the filename.
|
|
13
|
+
expect(localChangedFiles(fakeGit(' M src/checkout.ts\n'))).toEqual(['src/checkout.ts']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('parses a staged modification (trailing-space status code)', () => {
|
|
17
|
+
expect(localChangedFiles(fakeGit('M src/checkout.ts\n'))).toEqual(['src/checkout.ts']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses an untracked file', () => {
|
|
21
|
+
expect(localChangedFiles(fakeGit('?? new-file.txt\n'))).toEqual(['new-file.txt']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('parses a rename, keeping only the destination path', () => {
|
|
25
|
+
expect(localChangedFiles(fakeGit('R old-name.txt -> new-name.txt\n'))).toEqual(['new-name.txt']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('parses multiple changed files, sorted', () => {
|
|
29
|
+
expect(localChangedFiles(fakeGit(' M b.txt\n?? a.txt\n'))).toEqual(['a.txt', 'b.txt']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns an empty array for a clean worktree', () => {
|
|
33
|
+
expect(localChangedFiles(fakeGit(''))).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws when git status fails', () => {
|
|
37
|
+
const failing = () => ({ status: 1, stdout: '', stderr: 'not a git repository' });
|
|
38
|
+
expect(() => localChangedFiles(failing)).toThrow('not a git repository');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('badgr pull helpers', () => {
|
|
43
|
+
it('parses deployment id and safety flags', () => {
|
|
44
|
+
expect(parsePullArgs(['dep-123', '--branch'])).toEqual({ deploymentId: 'dep-123', flags: { branch: true, diffOnly: false, yes: false } });
|
|
45
|
+
expect(parsePullArgs(['dep-123', '--diff-only'])).toEqual({ deploymentId: 'dep-123', flags: { branch: false, diffOnly: true, yes: false } });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('parses --yes and -y as the same flag', () => {
|
|
49
|
+
expect(parsePullArgs(['dep-123', '--yes']).flags.yes).toBe(true);
|
|
50
|
+
expect(parsePullArgs(['dep-123', '-y']).flags.yes).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('defaults all safety flags to false when only the id is given', () => {
|
|
54
|
+
expect(parsePullArgs(['dep-123'])).toEqual({ deploymentId: 'dep-123', flags: { branch: false, diffOnly: false, yes: false } });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns undefined deploymentId when no positional arg is given', () => {
|
|
58
|
+
expect(parsePullArgs(['--branch']).deploymentId).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('extracts changed files from a git patch and detects conflicts', () => {
|
|
62
|
+
const patch = ['diff --git a/src/a.js b/src/a.js', '--- a/src/a.js', '+++ b/src/a.js', 'diff --git a/new.txt b/new.txt', '--- /dev/null', '+++ b/new.txt'].join('\n');
|
|
63
|
+
expect(changedFilesFromPatch(patch)).toEqual(['new.txt', 'src/a.js']);
|
|
64
|
+
expect(intersectFiles(['new.txt', 'src/a.js'], ['src/a.js', 'README.md'])).toEqual(['src/a.js']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('extracts changed files from a patch that only adds new files', () => {
|
|
68
|
+
const patch = ['diff --git a/new.txt b/new.txt', '--- /dev/null', '+++ b/new.txt'].join('\n');
|
|
69
|
+
expect(changedFilesFromPatch(patch)).toEqual(['new.txt']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns an empty conflict list when nothing overlaps', () => {
|
|
73
|
+
expect(intersectFiles(['a.js', 'b.js'], ['c.js'])).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Configurable per-test behavior — set inside each test before calling
|
|
78
|
+
// pullCommand, since vi.mock factories are hoisted and evaluated once.
|
|
79
|
+
let gitBehavior = {};
|
|
80
|
+
let tarWriteBehavior = () => {};
|
|
81
|
+
|
|
82
|
+
function resetGitBehavior() {
|
|
83
|
+
gitBehavior = {
|
|
84
|
+
revParse: () => ({ status: 0, stdout: 'true\n', stderr: '' }),
|
|
85
|
+
status: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
86
|
+
checkout: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
87
|
+
applyCheck: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
88
|
+
apply: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
vi.mock('child_process', () => ({
|
|
93
|
+
spawnSync: vi.fn((cmd, args) => {
|
|
94
|
+
if (args[0] === 'rev-parse') return gitBehavior.revParse();
|
|
95
|
+
if (args[0] === 'status') return gitBehavior.status();
|
|
96
|
+
if (args[0] === 'checkout') return gitBehavior.checkout(args);
|
|
97
|
+
if (args[0] === 'apply' && args[1] === '--check') return gitBehavior.applyCheck(args);
|
|
98
|
+
if (args[0] === 'apply') return gitBehavior.apply(args);
|
|
99
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
100
|
+
}),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
vi.mock('tar', () => {
|
|
104
|
+
const x = vi.fn(async opts => tarWriteBehavior(opts));
|
|
105
|
+
return { default: { x }, x };
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function writeNonPatchArtifact({ cwd }) {
|
|
109
|
+
fs.mkdirSync(`${cwd}/test-results`, { recursive: true });
|
|
110
|
+
fs.writeFileSync(`${cwd}/test-results/output.txt`, 'ok');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writePatchArtifact(patchText) {
|
|
114
|
+
return ({ cwd }) => {
|
|
115
|
+
fs.writeFileSync(`${cwd}/badgr-agent.patch`, patchText);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const CLEAN_PATCH = [
|
|
120
|
+
'diff --git a/src/checkout.ts b/src/checkout.ts',
|
|
121
|
+
'--- a/src/checkout.ts',
|
|
122
|
+
'+++ b/src/checkout.ts',
|
|
123
|
+
'@@ -1 +1 @@',
|
|
124
|
+
'-old',
|
|
125
|
+
'+new',
|
|
126
|
+
].join('\n');
|
|
127
|
+
|
|
128
|
+
describe('pullCommand', () => {
|
|
129
|
+
const config = { apiKey: 'test-key', baseUrl: 'https://example.test/v1' };
|
|
130
|
+
const chalk = { red: s => s, dim: s => s, green: s => s, yellow: s => s };
|
|
131
|
+
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
process.exitCode = undefined;
|
|
134
|
+
resetGitBehavior();
|
|
135
|
+
tarWriteBehavior = writeNonPatchArtifact;
|
|
136
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
137
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
138
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
139
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
140
|
+
ok: true,
|
|
141
|
+
arrayBuffer: async () => new ArrayBuffer(8),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
vi.restoreAllMocks();
|
|
147
|
+
process.exitCode = undefined;
|
|
148
|
+
delete global.fetch;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('prints a usage error and exits 1 when no deployment id is given', async () => {
|
|
152
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
153
|
+
await pullCommand(config, [], chalk);
|
|
154
|
+
expect(process.exitCode).toBe(1);
|
|
155
|
+
expect(console.error).toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('errors when not run inside a git worktree', async () => {
|
|
159
|
+
gitBehavior.revParse = () => ({ status: 0, stdout: 'false\n', stderr: '' });
|
|
160
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
161
|
+
await pullCommand(config, ['dep-abc'], chalk);
|
|
162
|
+
expect(process.exitCode).toBe(1);
|
|
163
|
+
const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
164
|
+
expect(logged).toContain('git worktree');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('reports a download failure clearly', async () => {
|
|
168
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', text: async () => '' });
|
|
169
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
170
|
+
await pullCommand(config, ['dep-missing'], chalk);
|
|
171
|
+
expect(process.exitCode).toBe(1);
|
|
172
|
+
const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
173
|
+
expect(logged).toContain('Could not pull dep-missing');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('points to `badgr artifacts <id>` instead of erroring when there is no code patch', async () => {
|
|
177
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
178
|
+
await pullCommand(config, ['dep-test-001'], chalk);
|
|
179
|
+
|
|
180
|
+
expect(process.exitCode).toBeUndefined();
|
|
181
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
182
|
+
expect(logged).toContain('badgr artifacts dep-test-001');
|
|
183
|
+
expect(logged).toContain('nothing to apply');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('applies cleanly when there are no local conflicts', async () => {
|
|
187
|
+
tarWriteBehavior = writePatchArtifact(CLEAN_PATCH);
|
|
188
|
+
gitBehavior.status = () => ({ status: 0, stdout: '', stderr: '' }); // no local changes at all
|
|
189
|
+
|
|
190
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
191
|
+
await pullCommand(config, ['dep-clean-001'], chalk);
|
|
192
|
+
|
|
193
|
+
expect(process.exitCode).toBeUndefined();
|
|
194
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
195
|
+
expect(logged).toContain('Applied cloud changes from dep-clean-001');
|
|
196
|
+
expect(logged).toContain('src/checkout.ts');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('refuses to overwrite when local edits conflict, without --branch', async () => {
|
|
200
|
+
tarWriteBehavior = writePatchArtifact(CLEAN_PATCH);
|
|
201
|
+
gitBehavior.status = () => ({ status: 0, stdout: ' M src/checkout.ts\n', stderr: '' });
|
|
202
|
+
|
|
203
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
204
|
+
await pullCommand(config, ['dep-conflict-001'], chalk);
|
|
205
|
+
|
|
206
|
+
expect(process.exitCode).toBe(1);
|
|
207
|
+
const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
208
|
+
expect(logged).toContain('conflict with local edits');
|
|
209
|
+
expect(logged).toContain('src/checkout.ts');
|
|
210
|
+
expect(logged).toContain('--branch');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('applies on a new branch when --branch is passed despite conflicts', async () => {
|
|
214
|
+
tarWriteBehavior = writePatchArtifact(CLEAN_PATCH);
|
|
215
|
+
gitBehavior.status = () => ({ status: 0, stdout: ' M src/checkout.ts\n', stderr: '' });
|
|
216
|
+
const checkoutSpy = vi.fn(() => ({ status: 0, stdout: '', stderr: '' }));
|
|
217
|
+
gitBehavior.checkout = checkoutSpy;
|
|
218
|
+
|
|
219
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
220
|
+
await pullCommand(config, ['dep-conflict-002', '--branch'], chalk);
|
|
221
|
+
|
|
222
|
+
expect(process.exitCode).toBeUndefined();
|
|
223
|
+
expect(checkoutSpy).toHaveBeenCalledWith(['checkout', '-b', 'badgr/dep-conflict-002']);
|
|
224
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
225
|
+
expect(logged).toContain('Created branch badgr/dep-conflict-002');
|
|
226
|
+
expect(logged).toContain('Applied cloud changes from dep-conflict-002');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('--diff-only prints the raw patch and does not apply anything', async () => {
|
|
230
|
+
tarWriteBehavior = writePatchArtifact(CLEAN_PATCH);
|
|
231
|
+
gitBehavior.status = () => ({ status: 0, stdout: ' M src/checkout.ts\n', stderr: '' });
|
|
232
|
+
|
|
233
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
234
|
+
await pullCommand(config, ['dep-diff-001', '--diff-only'], chalk);
|
|
235
|
+
|
|
236
|
+
expect(process.exitCode).toBeUndefined();
|
|
237
|
+
expect(process.stdout.write).toHaveBeenCalledWith(CLEAN_PATCH);
|
|
238
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
239
|
+
expect(logged).not.toContain('Applied cloud changes');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('surfaces a clear error when the patch does not apply cleanly', async () => {
|
|
243
|
+
tarWriteBehavior = writePatchArtifact(CLEAN_PATCH);
|
|
244
|
+
gitBehavior.status = () => ({ status: 0, stdout: '', stderr: '' });
|
|
245
|
+
gitBehavior.applyCheck = () => ({ status: 1, stdout: '', stderr: 'patch does not apply' });
|
|
246
|
+
|
|
247
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
248
|
+
await pullCommand(config, ['dep-bad-patch'], chalk);
|
|
249
|
+
|
|
250
|
+
expect(process.exitCode).toBe(1);
|
|
251
|
+
const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
|
|
252
|
+
expect(logged).toContain('patch does not apply');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('accepts --yes alongside --branch without changing the outcome', async () => {
|
|
256
|
+
tarWriteBehavior = writePatchArtifact(CLEAN_PATCH);
|
|
257
|
+
gitBehavior.status = () => ({ status: 0, stdout: ' M src/checkout.ts\n', stderr: '' });
|
|
258
|
+
|
|
259
|
+
const { pullCommand } = await import('../src/commands/pull.js');
|
|
260
|
+
await pullCommand(config, ['dep-conflict-003', '--branch', '--yes'], chalk);
|
|
261
|
+
|
|
262
|
+
expect(process.exitCode).toBeUndefined();
|
|
263
|
+
const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
|
|
264
|
+
expect(logged).toContain('Applied cloud changes from dep-conflict-003');
|
|
265
|
+
});
|
|
266
|
+
});
|