@patternfly/patternfly-cli 1.0.2
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/.github/workflows/build.yml +21 -0
- package/.github/workflows/lint.yml +24 -0
- package/.github/workflows/release.yml +42 -0
- package/.github/workflows/test.yml +24 -0
- package/.releaserc.json +14 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/__mocks__/execa.js +5 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +272 -0
- package/dist/cli.js.map +1 -0
- package/dist/github.d.ts +33 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +180 -0
- package/dist/github.js.map +1 -0
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +40 -0
- package/dist/load.js.map +1 -0
- package/dist/save.d.ts +6 -0
- package/dist/save.d.ts.map +1 -0
- package/dist/save.js +97 -0
- package/dist/save.js.map +1 -0
- package/dist/template-loader.d.ts +6 -0
- package/dist/template-loader.d.ts.map +1 -0
- package/dist/template-loader.js +83 -0
- package/dist/template-loader.js.map +1 -0
- package/dist/templates.d.ts +17 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +33 -0
- package/dist/templates.js.map +1 -0
- package/eslint.config.js +19 -0
- package/package.json +80 -0
- package/src/__tests__/cli.test.ts +171 -0
- package/src/__tests__/fixtures/invalid-template-bad-options.json +1 -0
- package/src/__tests__/fixtures/invalid-template-missing-name.json +1 -0
- package/src/__tests__/fixtures/not-array.json +1 -0
- package/src/__tests__/fixtures/valid-templates.json +14 -0
- package/src/__tests__/github.test.ts +214 -0
- package/src/__tests__/load.test.ts +106 -0
- package/src/__tests__/save.test.ts +272 -0
- package/src/cli.ts +307 -0
- package/src/github.ts +193 -0
- package/src/load.ts +38 -0
- package/src/save.ts +103 -0
- package/src/template-loader.ts +84 -0
- package/src/templates.ts +48 -0
- package/tsconfig.json +50 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
jest.mock('inquirer', () => ({
|
|
2
|
+
__esModule: true,
|
|
3
|
+
default: { prompt: jest.fn() },
|
|
4
|
+
}));
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import fs from 'fs-extra';
|
|
8
|
+
import { loadCustomTemplates, mergeTemplates } from '../template-loader.js';
|
|
9
|
+
import templates from '../templates.js';
|
|
10
|
+
import { sanitizeRepoName } from '../github.js';
|
|
11
|
+
|
|
12
|
+
const fixturesDir = path.join(process.cwd(), 'src', '__tests__', 'fixtures');
|
|
13
|
+
|
|
14
|
+
describe('loadCustomTemplates', () => {
|
|
15
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
16
|
+
throw new Error(`process.exit(${code})`);
|
|
17
|
+
}) as () => never);
|
|
18
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
consoleErrorSpy.mockClear();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
exitSpy.mockRestore();
|
|
26
|
+
consoleErrorSpy.mockRestore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('loads and parses a valid template file', () => {
|
|
30
|
+
const filePath = path.join(fixturesDir, 'valid-templates.json');
|
|
31
|
+
const result = loadCustomTemplates(filePath);
|
|
32
|
+
|
|
33
|
+
expect(result).toHaveLength(2);
|
|
34
|
+
expect(result[0]).toEqual({
|
|
35
|
+
name: 'custom-one',
|
|
36
|
+
description: 'A custom template',
|
|
37
|
+
repo: 'https://github.com/example/custom-one.git',
|
|
38
|
+
});
|
|
39
|
+
expect(result[1]).toEqual({
|
|
40
|
+
name: 'custom-with-options',
|
|
41
|
+
description: 'Custom with clone options',
|
|
42
|
+
repo: 'https://github.com/example/custom.git',
|
|
43
|
+
options: ['--depth', '1'],
|
|
44
|
+
packageManager: 'pnpm',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('exits when file does not exist', () => {
|
|
49
|
+
const filePath = path.join(fixturesDir, 'nonexistent.json');
|
|
50
|
+
|
|
51
|
+
expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)');
|
|
52
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
53
|
+
expect.stringContaining('Template file not found'),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('exits when file contains invalid JSON', async () => {
|
|
58
|
+
const invalidPath = path.join(fixturesDir, 'invalid-json.txt');
|
|
59
|
+
await fs.writeFile(invalidPath, 'not valid json {');
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
expect(() => loadCustomTemplates(invalidPath)).toThrow('process.exit(1)');
|
|
63
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
64
|
+
expect.stringContaining('Invalid JSON'),
|
|
65
|
+
);
|
|
66
|
+
} finally {
|
|
67
|
+
await fs.remove(invalidPath);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('exits when JSON is not an array', () => {
|
|
72
|
+
const filePath = path.join(fixturesDir, 'not-array.json');
|
|
73
|
+
|
|
74
|
+
expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)');
|
|
75
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
76
|
+
expect.stringContaining('must be a JSON array'),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('exits when template is missing required name', () => {
|
|
81
|
+
const filePath = path.join(fixturesDir, 'invalid-template-missing-name.json');
|
|
82
|
+
|
|
83
|
+
expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)');
|
|
84
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
85
|
+
expect.stringContaining('"name" must be'),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('exits when template has invalid options (non-string array)', () => {
|
|
90
|
+
const filePath = path.join(fixturesDir, 'invalid-template-bad-options.json');
|
|
91
|
+
|
|
92
|
+
expect(() => loadCustomTemplates(filePath)).toThrow('process.exit(1)');
|
|
93
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
94
|
+
expect.stringContaining('"options" must be'),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('mergeTemplates', () => {
|
|
100
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
101
|
+
throw new Error(`process.exit(${code})`);
|
|
102
|
+
}) as () => never);
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
exitSpy.mockRestore();
|
|
106
|
+
});
|
|
107
|
+
it('returns built-in templates when no custom file path is provided', () => {
|
|
108
|
+
const result = mergeTemplates(templates);
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual(templates);
|
|
111
|
+
expect(result).toHaveLength(templates.length);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns built-in templates when custom file path is undefined', () => {
|
|
115
|
+
const result = mergeTemplates(templates, undefined);
|
|
116
|
+
|
|
117
|
+
expect(result).toEqual(templates);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('merges custom templates with built-in, custom overrides by name', () => {
|
|
121
|
+
const customPath = path.join(fixturesDir, 'valid-templates.json');
|
|
122
|
+
const result = mergeTemplates(templates, customPath);
|
|
123
|
+
|
|
124
|
+
const names = result.map((t) => t.name);
|
|
125
|
+
expect(names).toContain('custom-one');
|
|
126
|
+
expect(names).toContain('custom-with-options');
|
|
127
|
+
|
|
128
|
+
const customOne = result.find((t) => t.name === 'custom-one');
|
|
129
|
+
expect(customOne?.repo).toBe('https://github.com/example/custom-one.git');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('overrides built-in template when custom has same name', async () => {
|
|
133
|
+
const builtInStarter = templates.find((t) => t.name === 'starter');
|
|
134
|
+
expect(builtInStarter).toBeDefined();
|
|
135
|
+
|
|
136
|
+
const customPath = path.join(fixturesDir, 'override-starter.json');
|
|
137
|
+
await fs.writeJson(customPath, [
|
|
138
|
+
{
|
|
139
|
+
name: 'starter',
|
|
140
|
+
description: 'Overridden starter',
|
|
141
|
+
repo: 'https://github.com/custom/overridden-starter.git',
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const result = mergeTemplates(templates, customPath);
|
|
147
|
+
const starter = result.find((t) => t.name === 'starter');
|
|
148
|
+
expect(starter?.description).toBe('Overridden starter');
|
|
149
|
+
expect(starter?.repo).toBe('https://github.com/custom/overridden-starter.git');
|
|
150
|
+
} finally {
|
|
151
|
+
await fs.remove(customPath);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('GitHub support (create command)', () => {
|
|
157
|
+
it('derives repo name from package name the same way the create command does', () => {
|
|
158
|
+
// Create command uses sanitizeRepoName(pkgJson.name) for initial repo name
|
|
159
|
+
expect(sanitizeRepoName('my-app')).toBe('my-app');
|
|
160
|
+
expect(sanitizeRepoName('@patternfly/my-project')).toBe('my-project');
|
|
161
|
+
expect(sanitizeRepoName('My Project Name')).toBe('my-project-name');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('builds repo URL in the format used by the create command', () => {
|
|
165
|
+
// Create command builds https://github.com/${auth.username}/${repoName}
|
|
166
|
+
const username = 'testuser';
|
|
167
|
+
const repoName = sanitizeRepoName('@org/my-package');
|
|
168
|
+
const repoUrl = `https://github.com/${username}/${repoName}`;
|
|
169
|
+
expect(repoUrl).toBe('https://github.com/testuser/my-package');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"name": "bad", "description": "Bad options", "repo": "https://example.com/repo.git", "options": [123]}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"description": "No name", "repo": "https://example.com/repo.git"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"templates": []}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "custom-one",
|
|
4
|
+
"description": "A custom template",
|
|
5
|
+
"repo": "https://github.com/example/custom-one.git"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"name": "custom-with-options",
|
|
9
|
+
"description": "Custom with clone options",
|
|
10
|
+
"repo": "https://github.com/example/custom.git",
|
|
11
|
+
"options": ["--depth", "1"],
|
|
12
|
+
"packageManager": "pnpm"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
jest.mock('execa', () => ({
|
|
2
|
+
__esModule: true,
|
|
3
|
+
execa: jest.fn(),
|
|
4
|
+
}));
|
|
5
|
+
|
|
6
|
+
jest.mock('inquirer', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: { prompt: jest.fn() },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
sanitizeRepoName,
|
|
13
|
+
checkGhAuth,
|
|
14
|
+
repoExists,
|
|
15
|
+
createRepo,
|
|
16
|
+
} from '../github.js';
|
|
17
|
+
import { execa as mockExeca } from 'execa';
|
|
18
|
+
|
|
19
|
+
describe('sanitizeRepoName', () => {
|
|
20
|
+
it('returns lowercase name with invalid chars replaced by hyphen', () => {
|
|
21
|
+
expect(sanitizeRepoName('My Project')).toBe('my-project');
|
|
22
|
+
expect(sanitizeRepoName('my_project')).toBe('my_project');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('strips npm scope and uses package name only', () => {
|
|
26
|
+
expect(sanitizeRepoName('@my-org/my-package')).toBe('my-package');
|
|
27
|
+
expect(sanitizeRepoName('@scope/package-name')).toBe('package-name');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('collapses multiple hyphens', () => {
|
|
31
|
+
expect(sanitizeRepoName('my---project')).toBe('my-project');
|
|
32
|
+
expect(sanitizeRepoName(' spaces ')).toBe('spaces');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('strips leading and trailing hyphens', () => {
|
|
36
|
+
expect(sanitizeRepoName('--my-project--')).toBe('my-project');
|
|
37
|
+
expect(sanitizeRepoName('-single-')).toBe('single');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('allows alphanumeric, hyphens, underscores, and dots', () => {
|
|
41
|
+
expect(sanitizeRepoName('my.project_1')).toBe('my.project_1');
|
|
42
|
+
expect(sanitizeRepoName('v1.0.0')).toBe('v1.0.0');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns "my-project" when result would be empty', () => {
|
|
46
|
+
expect(sanitizeRepoName('@scope/---')).toBe('my-project');
|
|
47
|
+
expect(sanitizeRepoName('!!!')).toBe('my-project');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles scoped package with only special chars after scope', () => {
|
|
51
|
+
expect(sanitizeRepoName('@org/---')).toBe('my-project');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('checkGhAuth', () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
mockExeca.mockReset();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns ok: false when gh auth status fails', async () => {
|
|
61
|
+
mockExeca.mockRejectedValueOnce(new Error('not logged in'));
|
|
62
|
+
|
|
63
|
+
const result = await checkGhAuth();
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
ok: false,
|
|
67
|
+
message: expect.stringContaining('GitHub CLI (gh) is not installed'),
|
|
68
|
+
});
|
|
69
|
+
expect(mockExeca).toHaveBeenCalledWith('gh', ['auth', 'status'], { reject: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns ok: false when gh api user returns empty login', async () => {
|
|
73
|
+
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
74
|
+
mockExeca.mockResolvedValueOnce({ stdout: '\n \n', stderr: '', exitCode: 0 });
|
|
75
|
+
|
|
76
|
+
const result = await checkGhAuth();
|
|
77
|
+
|
|
78
|
+
expect(result).toEqual({
|
|
79
|
+
ok: false,
|
|
80
|
+
message: expect.stringContaining('Could not determine your GitHub username'),
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns ok: false when gh api user throws', async () => {
|
|
85
|
+
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
86
|
+
mockExeca.mockRejectedValueOnce(new Error('API error'));
|
|
87
|
+
|
|
88
|
+
const result = await checkGhAuth();
|
|
89
|
+
|
|
90
|
+
expect(result).toEqual({
|
|
91
|
+
ok: false,
|
|
92
|
+
message: expect.stringContaining('Could not fetch your GitHub username'),
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns ok: true with username when auth and api succeed', async () => {
|
|
97
|
+
mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
98
|
+
mockExeca.mockResolvedValueOnce({
|
|
99
|
+
stdout: ' octocat ',
|
|
100
|
+
stderr: '',
|
|
101
|
+
exitCode: 0,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await checkGhAuth();
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual({ ok: true, username: 'octocat' });
|
|
107
|
+
expect(mockExeca).toHaveBeenNthCalledWith(2, 'gh', ['api', 'user', '--jq', '.login'], {
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('repoExists', () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
mockExeca.mockReset();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns true when gh api repos/owner/repo succeeds', async () => {
|
|
119
|
+
mockExeca.mockResolvedValueOnce({ stdout: '{}', stderr: '', exitCode: 0 });
|
|
120
|
+
|
|
121
|
+
const result = await repoExists('octocat', 'my-repo');
|
|
122
|
+
|
|
123
|
+
expect(result).toBe(true);
|
|
124
|
+
expect(mockExeca).toHaveBeenCalledWith('gh', ['api', 'repos/octocat/my-repo'], {
|
|
125
|
+
reject: true,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns false when gh api throws (e.g. 404)', async () => {
|
|
130
|
+
mockExeca.mockRejectedValueOnce(new Error('Not Found'));
|
|
131
|
+
|
|
132
|
+
const result = await repoExists('octocat', 'nonexistent');
|
|
133
|
+
|
|
134
|
+
expect(result).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('createRepo', () => {
|
|
139
|
+
const projectPath = '/tmp/my-app';
|
|
140
|
+
const username = 'octocat';
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
mockExeca.mockReset();
|
|
144
|
+
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('initializes git and calls gh repo create with expected args and returns repo URL', async () => {
|
|
148
|
+
mockExeca
|
|
149
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
150
|
+
.mockRejectedValueOnce(new Error('no HEAD'))
|
|
151
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
152
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
153
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
154
|
+
|
|
155
|
+
const url = await createRepo({
|
|
156
|
+
repoName: 'my-app',
|
|
157
|
+
projectPath,
|
|
158
|
+
username,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(url).toBe('https://github.com/octocat/my-app.git');
|
|
162
|
+
expect(mockExeca).toHaveBeenCalledTimes(5);
|
|
163
|
+
expect(mockExeca).toHaveBeenNthCalledWith(1, 'git', ['init'], {
|
|
164
|
+
stdio: 'inherit',
|
|
165
|
+
cwd: projectPath,
|
|
166
|
+
});
|
|
167
|
+
expect(mockExeca).toHaveBeenNthCalledWith(2, 'git', ['rev-parse', '--verify', 'HEAD'], expect.any(Object));
|
|
168
|
+
expect(mockExeca).toHaveBeenNthCalledWith(3, 'git', ['add', '.'], {
|
|
169
|
+
stdio: 'inherit',
|
|
170
|
+
cwd: projectPath,
|
|
171
|
+
});
|
|
172
|
+
expect(mockExeca).toHaveBeenNthCalledWith(4, 'git', ['commit', '-m', 'Initial commit'], {
|
|
173
|
+
stdio: 'inherit',
|
|
174
|
+
cwd: projectPath,
|
|
175
|
+
});
|
|
176
|
+
expect(mockExeca).toHaveBeenNthCalledWith(
|
|
177
|
+
5,
|
|
178
|
+
'gh',
|
|
179
|
+
[
|
|
180
|
+
'repo',
|
|
181
|
+
'create',
|
|
182
|
+
'my-app',
|
|
183
|
+
'--public',
|
|
184
|
+
`--source=${projectPath}`,
|
|
185
|
+
'--remote=origin',
|
|
186
|
+
'--push',
|
|
187
|
+
],
|
|
188
|
+
{ stdio: 'inherit', cwd: projectPath },
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('passes description when provided', async () => {
|
|
193
|
+
mockExeca
|
|
194
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
195
|
+
.mockRejectedValueOnce(new Error('no HEAD'))
|
|
196
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
197
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
198
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
199
|
+
|
|
200
|
+
await createRepo({
|
|
201
|
+
repoName: 'my-app',
|
|
202
|
+
projectPath,
|
|
203
|
+
username,
|
|
204
|
+
description: 'My cool project',
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(mockExeca).toHaveBeenNthCalledWith(
|
|
208
|
+
5,
|
|
209
|
+
'gh',
|
|
210
|
+
expect.arrayContaining(['--description=My cool project']),
|
|
211
|
+
expect.any(Object),
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
jest.mock('fs-extra', () => ({
|
|
2
|
+
__esModule: true,
|
|
3
|
+
default: {
|
|
4
|
+
pathExists: jest.fn(),
|
|
5
|
+
},
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
jest.mock('execa', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
execa: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import fs from 'fs-extra';
|
|
14
|
+
import { execa } from 'execa';
|
|
15
|
+
import { runLoad } from '../load.js';
|
|
16
|
+
|
|
17
|
+
const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
|
|
18
|
+
const mockExeca = execa as jest.MockedFunction<typeof execa>;
|
|
19
|
+
|
|
20
|
+
const cwd = '/tmp/my-repo';
|
|
21
|
+
|
|
22
|
+
describe('runLoad', () => {
|
|
23
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
24
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
consoleErrorSpy.mockRestore();
|
|
32
|
+
consoleLogSpy.mockRestore();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('throws and logs error when .git directory does not exist', async () => {
|
|
36
|
+
mockPathExists.mockResolvedValue(false);
|
|
37
|
+
|
|
38
|
+
await expect(runLoad(cwd)).rejects.toThrow('Not a git repository');
|
|
39
|
+
expect(mockPathExists).toHaveBeenCalledWith(`${cwd}/.git`);
|
|
40
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
41
|
+
expect.stringContaining('This directory is not a git repository'),
|
|
42
|
+
);
|
|
43
|
+
expect(mockExeca).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('runs git pull and logs success when repo exists', async () => {
|
|
47
|
+
mockPathExists.mockResolvedValue(true);
|
|
48
|
+
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
|
|
49
|
+
|
|
50
|
+
await runLoad(cwd);
|
|
51
|
+
|
|
52
|
+
expect(mockExeca).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(mockExeca).toHaveBeenCalledWith('git', ['pull'], {
|
|
54
|
+
cwd,
|
|
55
|
+
stdio: 'inherit',
|
|
56
|
+
});
|
|
57
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
58
|
+
expect.stringContaining('Pulling latest updates from GitHub'),
|
|
59
|
+
);
|
|
60
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
61
|
+
expect.stringContaining('Latest updates loaded successfully'),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('throws and logs pull-failure message when pull fails with exitCode 128', async () => {
|
|
66
|
+
mockPathExists.mockResolvedValue(true);
|
|
67
|
+
mockExeca.mockRejectedValueOnce(Object.assign(new Error('pull failed'), { exitCode: 128 }));
|
|
68
|
+
|
|
69
|
+
await expect(runLoad(cwd)).rejects.toMatchObject({ message: 'pull failed' });
|
|
70
|
+
|
|
71
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
72
|
+
expect.stringContaining('Pull failed'),
|
|
73
|
+
);
|
|
74
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
75
|
+
expect.stringContaining('remote'),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws and logs generic failure when pull fails with other exitCode', async () => {
|
|
80
|
+
mockPathExists.mockResolvedValue(true);
|
|
81
|
+
mockExeca.mockRejectedValueOnce(Object.assign(new Error('pull failed'), { exitCode: 1 }));
|
|
82
|
+
|
|
83
|
+
await expect(runLoad(cwd)).rejects.toMatchObject({ message: 'pull failed' });
|
|
84
|
+
|
|
85
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
86
|
+
expect.stringContaining('Pull failed'),
|
|
87
|
+
);
|
|
88
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
89
|
+
expect.stringContaining('See the output above'),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('throws and logs error when execa throws without exitCode', async () => {
|
|
94
|
+
mockPathExists.mockResolvedValue(true);
|
|
95
|
+
mockExeca.mockRejectedValueOnce(new Error('network error'));
|
|
96
|
+
|
|
97
|
+
await expect(runLoad(cwd)).rejects.toThrow('network error');
|
|
98
|
+
|
|
99
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
100
|
+
expect.stringContaining('An error occurred'),
|
|
101
|
+
);
|
|
102
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
103
|
+
expect.stringContaining('network error'),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|