@patternfly/patternfly-cli 1.0.2 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patternfly/patternfly-cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Patternfly cli for scaffolding projects, performing code mods, and running project related tasks",
5
5
  "author": "Red Hat",
6
6
  "license": "MIT",
@@ -50,7 +50,7 @@
50
50
  ]
51
51
  },
52
52
  "dependencies": {
53
- "0g": "^0.4.2",
53
+ "gh-pages": "^6.3.0",
54
54
  "commander": "^12.1.0",
55
55
  "execa": "^9.3.0",
56
56
  "fs-extra": "^11.2.0",
@@ -1,13 +1,7 @@
1
- jest.mock('inquirer', () => ({
2
- __esModule: true,
3
- default: { prompt: jest.fn() },
4
- }));
5
-
6
1
  import path from 'path';
7
2
  import fs from 'fs-extra';
8
3
  import { loadCustomTemplates, mergeTemplates } from '../template-loader.js';
9
4
  import templates from '../templates.js';
10
- import { sanitizeRepoName } from '../github.js';
11
5
 
12
6
  const fixturesDir = path.join(process.cwd(), 'src', '__tests__', 'fixtures');
13
7
 
@@ -152,20 +146,3 @@ describe('mergeTemplates', () => {
152
146
  }
153
147
  });
154
148
  });
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,306 @@
1
+ jest.mock('inquirer', () => ({
2
+ __esModule: true,
3
+ default: { prompt: jest.fn() },
4
+ }));
5
+
6
+ jest.mock('fs-extra', () => {
7
+ const real = jest.requireActual<typeof import('fs-extra')>('fs-extra');
8
+ return {
9
+ __esModule: true,
10
+ default: {
11
+ pathExists: jest.fn(),
12
+ readJson: jest.fn(),
13
+ writeJson: jest.fn(),
14
+ remove: jest.fn(),
15
+ existsSync: real.existsSync,
16
+ readFileSync: real.readFileSync,
17
+ },
18
+ };
19
+ });
20
+
21
+ jest.mock('execa', () => ({
22
+ __esModule: true,
23
+ execa: jest.fn(),
24
+ }));
25
+
26
+ jest.mock('../github.js', () => ({
27
+ ...jest.requireActual('../github.js'),
28
+ offerAndCreateGitHubRepo: jest.fn(),
29
+ }));
30
+
31
+ import path from 'path';
32
+ import fs from 'fs-extra';
33
+ import { execa } from 'execa';
34
+ import inquirer from 'inquirer';
35
+ import { sanitizeRepoName, offerAndCreateGitHubRepo } from '../github.js';
36
+ import { runCreate } from '../create.js';
37
+ import { defaultTemplates } from '../templates.js';
38
+
39
+ const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
40
+ const mockReadJson = fs.readJson as jest.MockedFunction<typeof fs.readJson>;
41
+ const mockWriteJson = fs.writeJson as jest.MockedFunction<typeof fs.writeJson>;
42
+ const mockRemove = fs.remove as jest.MockedFunction<typeof fs.remove>;
43
+ const mockExeca = execa as jest.MockedFunction<typeof execa>;
44
+ const mockPrompt = inquirer.prompt as jest.MockedFunction<typeof inquirer.prompt>;
45
+ const mockOfferAndCreateGitHubRepo = offerAndCreateGitHubRepo as jest.MockedFunction<
46
+ typeof offerAndCreateGitHubRepo
47
+ >;
48
+
49
+ const projectDir = 'my-test-project';
50
+ const projectPath = path.resolve(projectDir);
51
+
52
+ const projectData = {
53
+ name: 'my-app',
54
+ version: '1.0.0',
55
+ description: 'Test app',
56
+ author: 'Test Author',
57
+ };
58
+
59
+ function setupHappyPathMocks() {
60
+ mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
61
+ mockPathExists.mockResolvedValue(true);
62
+ mockReadJson.mockResolvedValue({ name: 'template-name', version: '0.0.0' });
63
+ mockWriteJson.mockResolvedValue(undefined);
64
+ mockRemove.mockResolvedValue(undefined);
65
+ mockOfferAndCreateGitHubRepo.mockResolvedValue(undefined);
66
+ mockPrompt.mockResolvedValue(projectData);
67
+ return projectData;
68
+ }
69
+
70
+ describe('GitHub support (create command)', () => {
71
+ it('derives repo name from package name the same way the create command does', () => {
72
+ expect(sanitizeRepoName('my-app')).toBe('my-app');
73
+ expect(sanitizeRepoName('@patternfly/my-project')).toBe('my-project');
74
+ expect(sanitizeRepoName('My Project Name')).toBe('my-project-name');
75
+ });
76
+
77
+ it('builds repo URL in the format used by the create command', () => {
78
+ const username = 'testuser';
79
+ const repoName = sanitizeRepoName('@org/my-package');
80
+ const repoUrl = `https://github.com/${username}/${repoName}`;
81
+ expect(repoUrl).toBe('https://github.com/testuser/my-package');
82
+ });
83
+ });
84
+
85
+ describe('runCreate', () => {
86
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
87
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
88
+
89
+ beforeEach(() => {
90
+ jest.clearAllMocks();
91
+ });
92
+
93
+ afterAll(() => {
94
+ consoleErrorSpy.mockRestore();
95
+ consoleLogSpy.mockRestore();
96
+ });
97
+
98
+ it('throws when template name is not found', async () => {
99
+ await expect(runCreate(projectDir, 'nonexistent-template')).rejects.toThrow(
100
+ 'Template "nonexistent-template" not found'
101
+ );
102
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
103
+ expect.stringContaining('Template "nonexistent-template" not found')
104
+ );
105
+ expect(mockExeca).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('prompts for project directory when not provided', async () => {
109
+ setupHappyPathMocks();
110
+
111
+ await runCreate(undefined, 'starter');
112
+
113
+ expect(mockPrompt).toHaveBeenCalledWith(
114
+ expect.arrayContaining([
115
+ expect.objectContaining({
116
+ type: 'input',
117
+ name: 'projectDirectory',
118
+ message: expect.stringContaining('directory where you want to create the project'),
119
+ default: 'my-app',
120
+ }),
121
+ ])
122
+ );
123
+ });
124
+
125
+ it('prompts for template when not provided', async () => {
126
+ setupHappyPathMocks();
127
+ mockPrompt
128
+ .mockResolvedValueOnce({ templateName: 'starter' })
129
+ .mockResolvedValueOnce(projectData);
130
+
131
+ await runCreate(projectDir, undefined);
132
+
133
+ expect(mockPrompt).toHaveBeenCalledWith(
134
+ expect.arrayContaining([
135
+ expect.objectContaining({
136
+ type: 'list',
137
+ name: 'templateName',
138
+ message: 'Select a template:',
139
+ choices: expect.any(Array),
140
+ }),
141
+ ])
142
+ );
143
+ });
144
+
145
+ it('uses HTTPS repo URL when ssh option is false', async () => {
146
+ setupHappyPathMocks();
147
+
148
+ await runCreate(projectDir, 'starter', { ssh: false });
149
+
150
+ const starter = defaultTemplates.find(t => t.name === 'starter');
151
+ expect(mockExeca).toHaveBeenNthCalledWith(
152
+ 1,
153
+ 'git',
154
+ ['clone', starter!.repo, projectPath],
155
+ expect.objectContaining({ stdio: 'inherit' })
156
+ );
157
+ });
158
+
159
+ it('uses SSH repo URL when ssh option is true and template has repoSSH', async () => {
160
+ setupHappyPathMocks();
161
+
162
+ await runCreate(projectDir, 'starter', { ssh: true });
163
+
164
+ const starter = defaultTemplates.find(t => t.name === 'starter');
165
+ expect(mockExeca).toHaveBeenNthCalledWith(
166
+ 1,
167
+ 'git',
168
+ ['clone', starter!.repoSSH, projectPath],
169
+ expect.objectContaining({ stdio: 'inherit' })
170
+ );
171
+ });
172
+
173
+ it('passes template clone options to git clone', async () => {
174
+ setupHappyPathMocks();
175
+
176
+ await runCreate(projectDir, 'compass-starter');
177
+
178
+ const compass = defaultTemplates.find(t => t.name === 'compass-starter');
179
+ expect(mockExeca).toHaveBeenNthCalledWith(
180
+ 1,
181
+ 'git',
182
+ ['clone', ...compass!.options!, compass!.repo, projectPath],
183
+ expect.any(Object)
184
+ );
185
+ });
186
+
187
+ it('removes .git from cloned project and customizes package.json', async () => {
188
+ setupHappyPathMocks();
189
+
190
+ await runCreate(projectDir, 'starter');
191
+
192
+ expect(mockRemove).toHaveBeenCalledWith(path.join(projectPath, '.git'));
193
+ expect(mockReadJson).toHaveBeenCalledWith(path.join(projectPath, 'package.json'));
194
+ expect(mockWriteJson).toHaveBeenCalledWith(
195
+ path.join(projectPath, 'package.json'),
196
+ expect.objectContaining({
197
+ name: projectData.name,
198
+ version: projectData.version,
199
+ description: projectData.description,
200
+ author: projectData.author,
201
+ }),
202
+ { spaces: 2 }
203
+ );
204
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Customized package.json'));
205
+ });
206
+
207
+ it('skips package.json customization when package.json does not exist', async () => {
208
+ setupHappyPathMocks();
209
+ mockPathExists.mockResolvedValue(false);
210
+
211
+ await runCreate(projectDir, 'starter');
212
+
213
+ expect(mockReadJson).not.toHaveBeenCalled();
214
+ expect(mockWriteJson).not.toHaveBeenCalled();
215
+ expect(consoleLogSpy).toHaveBeenCalledWith(
216
+ expect.stringContaining('No package.json found in template')
217
+ );
218
+ });
219
+
220
+ it('uses template packageManager and runs install', async () => {
221
+ setupHappyPathMocks();
222
+
223
+ await runCreate(projectDir, 'starter');
224
+
225
+ expect(mockExeca).toHaveBeenNthCalledWith(
226
+ 2,
227
+ 'yarn',
228
+ ['install'],
229
+ expect.objectContaining({ cwd: projectPath, stdio: 'inherit' })
230
+ );
231
+ });
232
+
233
+ it('falls back to npm when template has no packageManager', async () => {
234
+ setupHappyPathMocks();
235
+ // rhoai_enabled_starter has no packageManager
236
+ const rhoai = defaultTemplates.find(t => t.name === 'rhoai_enabled_starter');
237
+ expect(rhoai?.packageManager).toBeUndefined();
238
+
239
+ await runCreate(projectDir, 'rhoai_enabled_starter');
240
+
241
+ expect(mockExeca).toHaveBeenNthCalledWith(
242
+ 2,
243
+ 'npm',
244
+ ['install'],
245
+ expect.objectContaining({ cwd: projectPath, stdio: 'inherit' })
246
+ );
247
+ });
248
+
249
+ it('calls offerAndCreateGitHubRepo after install', async () => {
250
+ setupHappyPathMocks();
251
+
252
+ await runCreate(projectDir, 'starter');
253
+
254
+ expect(mockOfferAndCreateGitHubRepo).toHaveBeenCalledWith(projectPath);
255
+ });
256
+
257
+ it('logs success message with project directory', async () => {
258
+ setupHappyPathMocks();
259
+
260
+ await runCreate(projectDir, 'starter');
261
+
262
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Project created successfully'));
263
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(`cd ${projectDir}`));
264
+ });
265
+
266
+ it('cleans up project directory and rethrows when clone fails', async () => {
267
+ mockExeca.mockRejectedValueOnce(new Error('clone failed'));
268
+ mockPathExists.mockResolvedValue(true);
269
+
270
+ await expect(runCreate(projectDir, 'starter')).rejects.toThrow('clone failed');
271
+
272
+ expect(mockRemove).toHaveBeenCalledWith(projectPath);
273
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Cleaned up failed project directory'));
274
+ });
275
+
276
+ it('cleans up project directory and rethrows when install fails', async () => {
277
+ mockExeca
278
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>)
279
+ .mockRejectedValueOnce(new Error('install failed'));
280
+ mockPathExists.mockResolvedValue(true);
281
+ mockReadJson.mockResolvedValue({});
282
+ mockWriteJson.mockResolvedValue(undefined);
283
+ mockRemove.mockResolvedValue(undefined);
284
+ mockPrompt.mockResolvedValue(projectData);
285
+
286
+ await expect(runCreate(projectDir, 'starter')).rejects.toThrow('install failed');
287
+
288
+ expect(mockRemove).toHaveBeenCalledWith(projectPath);
289
+ });
290
+
291
+ it('uses custom templates when templateFile option is provided', async () => {
292
+ const fixturesDir = path.join(process.cwd(), 'src', '__tests__', 'fixtures');
293
+ const customPath = path.join(fixturesDir, 'valid-templates.json');
294
+ setupHappyPathMocks();
295
+ mockPrompt.mockResolvedValueOnce(projectData);
296
+
297
+ await runCreate(projectDir, 'custom-one', { templateFile: customPath });
298
+
299
+ expect(mockExeca).toHaveBeenNthCalledWith(
300
+ 1,
301
+ 'git',
302
+ ['clone', 'https://github.com/example/custom-one.git', projectPath],
303
+ expect.any(Object)
304
+ );
305
+ });
306
+ });
@@ -0,0 +1,283 @@
1
+ jest.mock('fs-extra', () => ({
2
+ __esModule: true,
3
+ default: {
4
+ pathExists: jest.fn(),
5
+ readJson: jest.fn(),
6
+ },
7
+ }));
8
+
9
+ jest.mock('execa', () => ({
10
+ __esModule: true,
11
+ execa: jest.fn(),
12
+ }));
13
+
14
+ jest.mock('gh-pages', () => ({
15
+ __esModule: true,
16
+ default: {
17
+ publish: jest.fn(),
18
+ },
19
+ }));
20
+
21
+ jest.mock('../github.js', () => ({
22
+ checkGhAuth: jest.fn().mockResolvedValue({ ok: false }),
23
+ }));
24
+
25
+ import path from 'path';
26
+ import fs from 'fs-extra';
27
+ import { execa } from 'execa';
28
+ import ghPages from 'gh-pages';
29
+ import { runDeployToGitHubPages } from '../gh-pages.js';
30
+
31
+ const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
32
+ const mockReadJson = fs.readJson as jest.MockedFunction<typeof fs.readJson>;
33
+ const mockExeca = execa as jest.MockedFunction<typeof execa>;
34
+ const mockGhPagesPublish = ghPages.publish as jest.MockedFunction<typeof ghPages.publish>;
35
+
36
+ const cwd = '/tmp/my-app';
37
+
38
+ /** Setup pathExists to return values based on path (avoids brittle call-order chains) */
39
+ function setupPathExists(checks: Record<string, boolean>) {
40
+ mockPathExists.mockImplementation((p: string) => {
41
+ for (const [key, value] of Object.entries(checks)) {
42
+ if (p.includes(key) || p === path.join(cwd, key)) return Promise.resolve(value);
43
+ }
44
+ return Promise.resolve(checks['*'] ?? false);
45
+ });
46
+ }
47
+
48
+ describe('runDeployToGitHubPages', () => {
49
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
50
+
51
+ beforeEach(() => {
52
+ jest.clearAllMocks();
53
+ });
54
+
55
+ afterAll(() => {
56
+ consoleLogSpy.mockRestore();
57
+ });
58
+
59
+ it('throws when package.json does not exist', async () => {
60
+ mockPathExists.mockImplementation((p: string) =>
61
+ Promise.resolve(path.join(cwd, 'package.json') !== p)
62
+ );
63
+
64
+ await expect(runDeployToGitHubPages(cwd)).rejects.toThrow(
65
+ 'No package.json found in this directory'
66
+ );
67
+ expect(mockPathExists).toHaveBeenCalledWith(path.join(cwd, 'package.json'));
68
+ expect(mockExeca).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it('throws when git remote origin is not configured', async () => {
72
+ setupPathExists({ 'package.json': true });
73
+ mockExeca.mockRejectedValue(new Error('not a git repo'));
74
+
75
+ await expect(runDeployToGitHubPages(cwd)).rejects.toThrow(
76
+ 'Please save your changes first, before deploying to GitHub Pages.'
77
+ );
78
+ expect(mockExeca).toHaveBeenCalledWith('git', ['remote', 'get-url', 'origin'], {
79
+ cwd,
80
+ reject: true,
81
+ });
82
+ });
83
+
84
+ it('throws when no build script and skipBuild is false', async () => {
85
+ setupPathExists({ 'package.json': true });
86
+ mockReadJson.mockResolvedValueOnce({ scripts: {} });
87
+ mockExeca.mockResolvedValue({
88
+ stdout: 'https://github.com/user/repo.git',
89
+ stderr: '',
90
+ exitCode: 0,
91
+ } as Awaited<ReturnType<typeof execa>>);
92
+
93
+ await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow(
94
+ 'No "build" script found in package.json'
95
+ );
96
+ expect(mockReadJson).toHaveBeenCalledWith(path.join(cwd, 'package.json'));
97
+ expect(mockExeca).toHaveBeenCalledTimes(1); // only git
98
+ });
99
+
100
+ it('runs build then deploys when skipBuild is false (npm)', async () => {
101
+ setupPathExists({ 'package.json': true, 'dist': true });
102
+ mockReadJson.mockResolvedValueOnce({
103
+ scripts: { build: 'webpack --config webpack.prod.js' },
104
+ });
105
+ mockExeca.mockResolvedValue({
106
+ stdout: 'https://github.com/user/repo.git',
107
+ stderr: '',
108
+ exitCode: 0,
109
+ } as Awaited<ReturnType<typeof execa>>);
110
+ mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
111
+
112
+ await runDeployToGitHubPages(cwd, { skipBuild: false });
113
+
114
+ expect(mockExeca).toHaveBeenCalledTimes(2); // git, npm run build
115
+ expect(mockExeca).toHaveBeenNthCalledWith(2, 'npm', ['run', 'build'], {
116
+ cwd,
117
+ stdio: 'inherit',
118
+ });
119
+ expect(mockGhPagesPublish).toHaveBeenCalledWith(
120
+ path.join(cwd, 'dist'),
121
+ { branch: 'gh-pages', repo: 'https://github.com/user/repo.git' },
122
+ expect.any(Function)
123
+ );
124
+ expect(consoleLogSpy).toHaveBeenCalledWith(
125
+ expect.stringContaining('Running build')
126
+ );
127
+ expect(consoleLogSpy).toHaveBeenCalledWith(
128
+ expect.stringContaining('Deployed to GitHub Pages')
129
+ );
130
+ });
131
+
132
+ it('uses yarn when yarn.lock exists', async () => {
133
+ setupPathExists({ 'package.json': true, 'yarn.lock': true, 'dist': true });
134
+ mockReadJson.mockResolvedValueOnce({
135
+ scripts: { build: 'webpack' },
136
+ });
137
+ mockExeca.mockResolvedValue({
138
+ stdout: 'https://github.com/user/repo.git',
139
+ stderr: '',
140
+ exitCode: 0,
141
+ } as Awaited<ReturnType<typeof execa>>);
142
+ mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
143
+
144
+ await runDeployToGitHubPages(cwd, { skipBuild: false });
145
+
146
+ expect(mockExeca).toHaveBeenNthCalledWith(2, 'yarn', ['build'], {
147
+ cwd,
148
+ stdio: 'inherit',
149
+ });
150
+ });
151
+
152
+ it('uses pnpm when pnpm-lock.yaml exists (and no yarn.lock)', async () => {
153
+ setupPathExists({ 'package.json': true, 'pnpm-lock.yaml': true, 'dist': true });
154
+ mockReadJson.mockResolvedValueOnce({
155
+ scripts: { build: 'vite build' },
156
+ });
157
+ mockExeca.mockResolvedValue({
158
+ stdout: 'https://github.com/user/repo.git',
159
+ stderr: '',
160
+ exitCode: 0,
161
+ } as Awaited<ReturnType<typeof execa>>);
162
+ mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
163
+
164
+ await runDeployToGitHubPages(cwd, { skipBuild: false });
165
+
166
+ expect(mockExeca).toHaveBeenNthCalledWith(2, 'pnpm', ['build'], {
167
+ cwd,
168
+ stdio: 'inherit',
169
+ });
170
+ });
171
+
172
+ it('skips build and deploys when skipBuild is true', async () => {
173
+ setupPathExists({ 'package.json': true, 'dist': true });
174
+ mockExeca.mockResolvedValue({
175
+ stdout: 'https://github.com/user/repo.git',
176
+ stderr: '',
177
+ exitCode: 0,
178
+ } as Awaited<ReturnType<typeof execa>>);
179
+ mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
180
+
181
+ await runDeployToGitHubPages(cwd, { skipBuild: true });
182
+
183
+ expect(mockReadJson).not.toHaveBeenCalled();
184
+ expect(mockExeca).toHaveBeenCalledTimes(1); // git only
185
+ expect(mockGhPagesPublish).toHaveBeenCalledWith(
186
+ path.join(cwd, 'dist'),
187
+ { branch: 'gh-pages', repo: 'https://github.com/user/repo.git' },
188
+ expect.any(Function)
189
+ );
190
+ });
191
+
192
+ it('throws when dist directory does not exist (after build)', async () => {
193
+ setupPathExists({ 'package.json': true, 'dist': false });
194
+ mockReadJson.mockResolvedValueOnce({
195
+ scripts: { build: 'npm run build' },
196
+ });
197
+ mockExeca.mockResolvedValue({
198
+ stdout: 'https://github.com/user/repo.git',
199
+ stderr: '',
200
+ exitCode: 0,
201
+ } as Awaited<ReturnType<typeof execa>>);
202
+
203
+ await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow(
204
+ 'Build output directory "dist" does not exist'
205
+ );
206
+ expect(mockExeca).toHaveBeenCalledTimes(2); // git, npm run build
207
+ });
208
+
209
+ it('throws when dist directory does not exist with skipBuild true', async () => {
210
+ setupPathExists({ 'package.json': true, 'dist': false });
211
+ mockExeca.mockResolvedValue({
212
+ stdout: 'https://github.com/user/repo.git',
213
+ stderr: '',
214
+ exitCode: 0,
215
+ } as Awaited<ReturnType<typeof execa>>);
216
+
217
+ await expect(runDeployToGitHubPages(cwd, { skipBuild: true })).rejects.toThrow(
218
+ 'Build output directory "dist" does not exist'
219
+ );
220
+ expect(mockExeca).toHaveBeenCalledTimes(1); // git only
221
+ });
222
+
223
+ it('uses custom distDir and branch options', async () => {
224
+ setupPathExists({ 'package.json': true, 'build': true });
225
+ mockExeca.mockResolvedValue({
226
+ stdout: 'https://github.com/user/repo.git',
227
+ stderr: '',
228
+ exitCode: 0,
229
+ } as Awaited<ReturnType<typeof execa>>);
230
+ mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
231
+
232
+ await runDeployToGitHubPages(cwd, {
233
+ skipBuild: true,
234
+ distDir: 'build',
235
+ branch: 'pages',
236
+ });
237
+
238
+ expect(mockGhPagesPublish).toHaveBeenCalledWith(
239
+ path.join(cwd, 'build'),
240
+ { branch: 'pages', repo: 'https://github.com/user/repo.git' },
241
+ expect.any(Function)
242
+ );
243
+ expect(consoleLogSpy).toHaveBeenCalledWith(
244
+ expect.stringContaining('Deploying "build" to GitHub Pages (branch: pages)')
245
+ );
246
+ });
247
+
248
+ it('propagates build failure', async () => {
249
+ setupPathExists({ 'package.json': true });
250
+ mockReadJson.mockResolvedValueOnce({
251
+ scripts: { build: 'webpack' },
252
+ });
253
+ mockExeca
254
+ .mockResolvedValueOnce({
255
+ stdout: 'https://github.com/user/repo.git',
256
+ stderr: '',
257
+ exitCode: 0,
258
+ } as Awaited<ReturnType<typeof execa>>)
259
+ .mockRejectedValueOnce(new Error('Build failed'));
260
+
261
+ await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow(
262
+ 'Build failed'
263
+ );
264
+ expect(mockExeca).toHaveBeenCalledTimes(2); // git, npm run build
265
+ });
266
+
267
+ it('propagates gh-pages deploy failure', async () => {
268
+ setupPathExists({ 'package.json': true, 'dist': true });
269
+ mockExeca.mockResolvedValue({
270
+ stdout: 'https://github.com/user/repo.git',
271
+ stderr: '',
272
+ exitCode: 0,
273
+ } as Awaited<ReturnType<typeof execa>>);
274
+ mockGhPagesPublish.mockImplementation((_dir, _opts, cb) =>
275
+ cb(new Error('Deploy failed'))
276
+ );
277
+
278
+ await expect(runDeployToGitHubPages(cwd, { skipBuild: true })).rejects.toThrow(
279
+ 'Deploy failed'
280
+ );
281
+ expect(mockExeca).toHaveBeenCalledTimes(1); // git only
282
+ });
283
+ });