-
Notifications
You must be signed in to change notification settings - Fork 98
Expand file tree
/
Copy pathengine.spec.ts
More file actions
460 lines (353 loc) · 17 KB
/
Copy pathengine.spec.ts
File metadata and controls
460 lines (353 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
import { logging } from '@angular-devkit/core';
import * as engine from './engine';
import { cleanupMonkeypatch } from './engine.prepare-options-helpers';
describe('engine', () => {
describe('prepareOptions', () => {
const logger = new logging.NullLogger();
const originalEnv = process.env;
beforeEach(() => {
// Clean up any previous monkeypatch so each test starts fresh
cleanupMonkeypatch();
// Create fresh copy of environment for each test
// This preserves PATH, HOME, etc. needed by git
process.env = { ...originalEnv };
// Clear only CI-specific vars we're testing
delete process.env.TRAVIS;
delete process.env.CIRCLECI;
delete process.env.GITHUB_ACTIONS;
delete process.env.GH_TOKEN;
delete process.env.PERSONAL_TOKEN;
delete process.env.GITHUB_TOKEN;
});
afterAll(() => {
// Clean up monkeypatch after all tests
cleanupMonkeypatch();
// Restore original environment for other test files
process.env = originalEnv;
});
it('should replace the string GH_TOKEN in the repo url (for backwards compatibility)', async () => {
const options = {
repo: 'https://GH_TOKEN@github.com/organisation/your-repo.git'
};
process.env.GH_TOKEN = 'XXX';
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.repo).toBe(
'https://XXX@github.com/organisation/your-repo.git'
);
});
// see https://github.com/EdricChan03/rss-reader/commit/837dc10c18bfa453c586bb564a662e7dad1e68ab#r36665276 as an example
it('should be possible to use GH_TOKEN in repo url as a workaround for other tokens (for backwards compatibility)', async () => {
const options = {
repo:
'https://x-access-token:GH_TOKEN@github.com/organisation/your-repo.git'
};
process.env.GH_TOKEN = 'XXX';
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.repo).toBe(
'https://x-access-token:XXX@github.com/organisation/your-repo.git'
);
});
// ----
it('should also add a personal access token (GH_TOKEN) to the repo url', async () => {
const options = {
repo: 'https://github.com/organisation/your-repo.git'
};
process.env.GH_TOKEN = 'XXX';
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.repo).toBe(
'https://x-access-token:XXX@github.com/organisation/your-repo.git'
);
});
it('should also add a personal access token (PERSONAL_TOKEN) to the repo url', async () => {
const options = {
repo: 'https://github.com/organisation/your-repo.git'
};
process.env.PERSONAL_TOKEN = 'XXX';
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.repo).toBe(
'https://x-access-token:XXX@github.com/organisation/your-repo.git'
);
});
it('should also add a installation access token (GITHUB_TOKEN) to the repo url', async () => {
const options = {
repo: 'https://github.com/organisation/your-repo.git'
};
process.env.GITHUB_TOKEN = 'XXX';
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.repo).toBe(
'https://x-access-token:XXX@github.com/organisation/your-repo.git'
);
});
// NEW in 0.6.2: always discover remote URL (if not set)
/**
* Environment assumptions for this test:
* - Tests must be run from a git clone of angular-schule/angular-cli-ghpages
* - The "origin" remote must exist and point to that repository
* - git must be installed and on PATH
*
* If run from a bare copy of files (no .git), this test will fail by design.
*/
// this allows us to inject tokens from environment even if --repo is not set manually
// it uses gh-pages lib directly for this
it('should discover the remote url, if no --repo is set', async () => {
const options = {};
const finalOptions = await engine.prepareOptions(options, logger);
// Justification for .toContain():
// The protocol (SSH vs HTTPS) depends on developer's git config.
// Our testing philosophy allows .toContain() for substrings in long/variable messages.
// We only care that the correct repo path is discovered.
expect(finalOptions.repo).toContain('angular-schule/angular-cli-ghpages');
});
describe('remote', () => {
it('should use the provided remote if --remote is set', async () => {
const options = { remote: 'foobar', repo: 'xxx' };
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.remote).toBe('foobar');
});
it('should use the origin remote if --remote is not set', async () => {
const options = { repo: 'xxx' };
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.remote).toBe('origin');
});
});
});
describe('prepareOptions - handling dotfiles, notfound, and nojekyll', () => {
const logger = new logging.NullLogger();
it('should set dotfiles, notfound, and nojekyll to false when no- flags are given', async () => {
const options = {
noDotfiles: true,
noNotfound: true,
noNojekyll: true
};
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.dotfiles).toBe(false);
expect(finalOptions.notfound).toBe(false);
expect(finalOptions.nojekyll).toBe(false);
});
it('should set dotfiles, notfound, and nojekyll to true when no- flags are not given', async () => {
const options = {};
const finalOptions = await engine.prepareOptions(options, logger);
expect(finalOptions.dotfiles).toBe(true);
expect(finalOptions.notfound).toBe(true);
expect(finalOptions.nojekyll).toBe(true);
});
});
describe('run - dist folder validation', () => {
const logger = new logging.NullLogger();
it('should throw error when dist folder does not exist', async () => {
// This test proves the CRITICAL operator precedence bug was fixed
// BUG: await !fse.pathExists(dir) - applies ! to Promise (always false, error NEVER thrown)
// FIX: !(await fse.pathExists(dir)) - awaits first, then negates (error IS thrown)
// Mock gh-pages module
jest.mock('gh-pages', () => ({
clean: jest.fn(),
publish: jest.fn()
}));
// Mock pathExists from utils to return false
const utils = require('../utils');
jest.spyOn(utils, 'pathExists').mockResolvedValue(false);
const nonExistentDir = '/path/to/nonexistent/dir';
const expectedErrorMessage = 'Dist folder does not exist. Check the dir --dir parameter or build the project first!';
await expect(
engine.run(nonExistentDir, { dotfiles: true, notfound: true, nojekyll: true }, logger)
).rejects.toThrow(expectedErrorMessage);
expect(utils.pathExists).toHaveBeenCalledWith(nonExistentDir);
});
});
describe('prepareOptions - user credentials warnings', () => {
it('should warn when only name is set without email', async () => {
const testLogger = new logging.Logger('test');
const warnSpy = jest.spyOn(testLogger, 'warn');
const options = { name: 'John Doe' };
const expectedWarning = 'WARNING: Both --name and --email must be set together to configure git user. Only --name is set. Git will use the local or global git config instead.';
await engine.prepareOptions(options, testLogger);
expect(warnSpy).toHaveBeenCalledWith(expectedWarning);
});
it('should warn when only email is set without name', async () => {
const testLogger = new logging.Logger('test');
const warnSpy = jest.spyOn(testLogger, 'warn');
const options = { email: 'john@example.com' };
const expectedWarning = 'WARNING: Both --name and --email must be set together to configure git user. Only --email is set. Git will use the local or global git config instead.';
await engine.prepareOptions(options, testLogger);
expect(warnSpy).toHaveBeenCalledWith(expectedWarning);
});
it('should NOT warn when both name and email are set', async () => {
const testLogger = new logging.Logger('test');
const warnSpy = jest.spyOn(testLogger, 'warn');
const options = { name: 'John Doe', email: 'john@example.com' };
const finalOptions = await engine.prepareOptions(options, testLogger);
expect(finalOptions.user).toEqual({ name: 'John Doe', email: 'john@example.com' });
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('name and --email must be set together'));
});
});
describe('prepareOptions - deprecated noSilent warning', () => {
it('should warn when noSilent parameter is used', async () => {
const testLogger = new logging.Logger('test');
const warnSpy = jest.spyOn(testLogger, 'warn');
const options = { noSilent: true };
const expectedWarning = 'The --no-silent parameter is deprecated and no longer needed. Verbose logging is now always enabled. This parameter will be ignored.';
await engine.prepareOptions(options, testLogger);
expect(warnSpy).toHaveBeenCalledWith(expectedWarning);
});
it('should NOT warn when noSilent is not provided', async () => {
const testLogger = new logging.Logger('test');
const warnSpy = jest.spyOn(testLogger, 'warn');
const options = {};
await engine.prepareOptions(options, testLogger);
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('no-silent'));
});
});
describe('run - gh-pages Promise error handling', () => {
// gh-pages v5+ supports Promise-based API (fixed the bug where early errors didn't reject)
// We now use await ghPages.publish() directly instead of callback-based approach
const logger = new logging.NullLogger();
let pathExistsSpy: jest.SpyInstance;
let ghpagesCleanSpy: jest.SpyInstance;
let ghpagesPublishSpy: jest.SpyInstance;
beforeEach(() => {
// Setup persistent mocks for utils.pathExists
const utils = require('../utils');
pathExistsSpy = jest.spyOn(utils, 'pathExists').mockResolvedValue(true);
// Setup persistent mocks for gh-pages
const ghpages = require('gh-pages');
ghpagesCleanSpy = jest.spyOn(ghpages, 'clean').mockImplementation(() => {});
ghpagesPublishSpy = jest.spyOn(ghpages, 'publish');
});
afterEach(() => {
// Clean up spies
pathExistsSpy.mockRestore();
ghpagesCleanSpy.mockRestore();
ghpagesPublishSpy.mockRestore();
});
// engine uses the callback form of gh-pages.publish() — see #205
const mockPublishCallback = (error: Error | null) =>
(_dir: unknown, _opts: unknown, callback?: (err: Error | null) => void) => {
if (callback) {
callback(error);
}
return Promise.resolve(undefined);
};
it('should reject when gh-pages.publish rejects with error', async () => {
const publishError = new Error('Git push failed: permission denied');
ghpagesPublishSpy.mockImplementation(mockPublishCallback(publishError));
const testDir = '/test/dist';
const options = { dotfiles: true, notfound: true, nojekyll: true };
await expect(
engine.run(testDir, options, logger)
).rejects.toThrow('Git push failed: permission denied');
});
it('should preserve error message through rejection', async () => {
const detailedError = new Error('Remote url mismatch. Expected https://github.com/user/repo.git but got https://github.com/other/repo.git');
ghpagesPublishSpy.mockImplementation(mockPublishCallback(detailedError));
const testDir = '/test/dist';
const options = { dotfiles: true, notfound: true, nojekyll: true };
await expect(
engine.run(testDir, options, logger)
).rejects.toThrow(detailedError);
});
it('should reject with authentication error from gh-pages', async () => {
const authError = new Error('Authentication failed: Invalid credentials');
ghpagesPublishSpy.mockImplementation(mockPublishCallback(authError));
const testDir = '/test/dist';
const options = { dotfiles: true, notfound: true, nojekyll: true };
await expect(
engine.run(testDir, options, logger)
).rejects.toThrow('Authentication failed: Invalid credentials');
});
it('should resolve successfully when gh-pages.publish resolves', async () => {
ghpagesPublishSpy.mockImplementation(mockPublishCallback(null));
const testDir = '/test/dist';
const options = { dotfiles: true, notfound: true, nojekyll: true };
await expect(
engine.run(testDir, options, logger)
).resolves.toBeUndefined();
});
});
describe('prepareOptions - monkeypatch verification', () => {
beforeEach(() => {
// Clean up monkeypatch before each test to start fresh
cleanupMonkeypatch();
});
afterEach(() => {
// Clean up monkeypatch after each test
cleanupMonkeypatch();
});
it('should replace util.debuglog with custom implementation', async () => {
const testLogger = new logging.Logger('test');
const util = require('util');
const debuglogBeforePrepare = util.debuglog;
await engine.prepareOptions({}, testLogger);
// After prepareOptions, util.debuglog should be replaced
expect(util.debuglog).not.toBe(debuglogBeforePrepare);
});
it('should forward gh-pages debuglog calls to Angular logger', async () => {
const testLogger = new logging.Logger('test');
const infoSpy = jest.spyOn(testLogger, 'info');
await engine.prepareOptions({}, testLogger);
// Now get the patched debuglog for 'gh-pages'
const util = require('util');
const ghPagesLogger = util.debuglog('gh-pages');
// Call it with a test message
const testMessage = 'Publishing to gh-pages branch';
ghPagesLogger(testMessage);
// Should have forwarded to logger.info()
expect(infoSpy).toHaveBeenCalledWith(testMessage);
});
it('should forward gh-pages debuglog calls with formatting to Angular logger', async () => {
const testLogger = new logging.Logger('test');
const infoSpy = jest.spyOn(testLogger, 'info');
await engine.prepareOptions({}, testLogger);
const util = require('util');
const ghPagesLogger = util.debuglog('gh-pages');
// Test with util.format style placeholders
ghPagesLogger('Pushing %d files to %s', 42, 'gh-pages');
// Should format the message and forward to logger.info()
expect(infoSpy).toHaveBeenCalledWith('Pushing 42 files to gh-pages');
});
it('should call original debuglog for non-gh-pages modules', async () => {
const testLogger = new logging.Logger('test');
const infoSpy = jest.spyOn(testLogger, 'info');
const util = require('util');
const originalDebuglogFn = util.debuglog;
const originalDebuglogSpy = jest.fn(originalDebuglogFn);
util.debuglog = originalDebuglogSpy;
await engine.prepareOptions({}, testLogger);
// Now util.debuglog is patched
const otherModuleLogger = util.debuglog('some-other-module');
// Should have called the original debuglog (via our spy)
expect(originalDebuglogSpy).toHaveBeenCalledWith('some-other-module');
// Should NOT have forwarded to Angular logger
expect(infoSpy).not.toHaveBeenCalled();
});
it('should monkeypatch util.debuglog before requiring gh-pages', async () => {
// This test verifies the critical ordering requirement:
// The monkeypatch MUST occur before requiring gh-pages, otherwise gh-pages caches
// the original util.debuglog and our interception won't work.
const testLogger = new logging.Logger('test');
const infoSpy = jest.spyOn(testLogger, 'info');
// Clear gh-pages from require cache to simulate fresh load
const ghPagesPath = require.resolve('gh-pages');
delete require.cache[ghPagesPath];
await engine.prepareOptions({}, testLogger);
// Now require gh-pages for the first time (after monkeypatch)
require('gh-pages');
// Verify our patched debuglog('gh-pages') forwards to the logger
const util = require('util');
const ghPagesLogger = util.debuglog('gh-pages');
ghPagesLogger('test message');
expect(infoSpy).toHaveBeenCalledWith('test message');
});
it('should restore original util.debuglog when cleanupMonkeypatch is called', async () => {
const testLogger = new logging.Logger('test');
const util = require('util');
const debuglogBeforeSetup = util.debuglog;
await engine.prepareOptions({}, testLogger);
// After prepareOptions, util.debuglog should be patched
expect(util.debuglog).not.toBe(debuglogBeforeSetup);
// Call cleanup
cleanupMonkeypatch();
// After cleanup, util.debuglog should be restored
expect(util.debuglog).toBe(debuglogBeforeSetup);
});
});
});