Skip to content

Commit 713b801

Browse files
committed
fix: implement all audit findings with comprehensive improvements
CRITICAL BUG FIX: - Fix operator precedence bug in checkIfDistFolderExists (engine.ts:195) * Before: await !fse.pathExists(dir) - error NEVER thrown * After: !(await fse.pathExists(dir)) - error correctly thrown - Add regression test proving the bug was fixed TYPE SAFETY (eliminate ALL 'any' violations): - Create PublishOptions interface with full gh-pages options - Fix GHPages.publish signature: any → PublishOptions, Error | null - Add DryRunOutput interface for CLI test parsing - Fix WorkspaceProject and BuildTarget with proper types - Remove 'as any' casts using Partial<BuilderContext> - Enable noImplicitAny: true in tsconfig.json - Create type declaration for gh-pages/lib/git module - Use unknown[] instead of any[] in util.debuglog monkeypatch TESTING PHILOSOPHY COMPLIANCE: - Change .toMatch() to .toBe() with exact expected values - Add justified exception for git remote URL test (protocol varies) - Remove legacy angular-cli-ghpages.spec.ts (violates philosophy) - All 188 tests use explicit assertions per CLAUDE.md NEW FEATURES: - Warn when only --name OR only --email is set (both required) - Warn when deprecated --no-silent parameter is used - Improve error messages in CLI E2E test JSON parsing DOCUMENTATION: - Fix file paths in CLAUDE.md (engine/ → parameter-tests/) - Add clarifying comments to builder-integration.spec.ts - Document what's REAL vs MOCKED in integration tests VERIFICATION: - 188 tests passing (added 5 new warning tests) - Build passes with noImplicitAny: true - Zero 'any' types in production code - Zero testing philosophy violations
1 parent 8820b96 commit 713b801

File tree

10 files changed

+215
-90
lines changed

10 files changed

+215
-90
lines changed

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,13 @@ Angular CLI does NOT rename kebab-case to camelCase for boolean flags with "no"
193193
## Testing Strategy
194194

195195
Tests use Jest and are located alongside source files with `.spec.ts` extension:
196-
- `angular-cli-ghpages.spec.ts` - Standalone CLI tests
197196
- `deploy/actions.spec.ts` - Deployment action tests
198197
- `engine/engine.spec.ts` - Core engine tests
199198
- `ng-add.spec.ts` - Schematic tests
200-
- `engine/parameter-passthrough.spec.ts` - Parameter transformation tests
201-
- `engine/edge-cases.spec.ts` - Edge case and boundary tests
202-
- `cli-e2e.spec.ts` - End-to-end CLI testing
199+
- `parameter-tests/parameter-passthrough.spec.ts` - Parameter transformation tests
200+
- `parameter-tests/edge-cases.spec.ts` - Edge case and boundary tests
201+
- `parameter-tests/cli-e2e.spec.ts` - End-to-end standalone CLI testing
202+
- `parameter-tests/builder-integration.spec.ts` - Angular Builder integration tests
203203

204204
Snapshot tests are stored in `__snapshots__/` directory.
205205

src/angular-cli-ghpages.spec.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

src/deploy/actions.spec.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ import {
77
} from '@angular-devkit/architect/src';
88
import { JsonObject, logging } from '@angular-devkit/core';
99
import { BuildTarget } from '../interfaces';
10+
import { Schema } from './schema';
1011

1112
import deploy from './actions';
1213

14+
interface EngineHost {
15+
run(dir: string, options: Schema, logger: logging.LoggerApi): Promise<void>;
16+
}
17+
1318
let context: BuilderContext;
14-
const mockEngine = { run: (_: string, __: any, __2: any) => Promise.resolve() };
19+
const mockEngine: EngineHost = { run: (_: string, __: Schema, __2: logging.LoggerApi) => Promise.resolve() };
1520

1621
const PROJECT = 'pirojok-project';
1722
const BUILD_TARGET: BuildTarget = {
@@ -59,11 +64,12 @@ describe('Deploy Angular apps', () => {
5964
describe('error handling', () => {
6065
it('throws if there is no target project', async () => {
6166
context.target = undefined;
67+
const expectedErrorMessage = 'Cannot execute the build target';
6268
try {
6369
await deploy(mockEngine, context, BUILD_TARGET, {});
6470
fail();
6571
} catch (e) {
66-
expect(e.message).toMatch(/Cannot execute the build target/);
72+
expect(e.message).toBe(expectedErrorMessage);
6773
}
6874
});
6975

@@ -87,7 +93,7 @@ describe('Deploy Angular apps', () => {
8793
});
8894

8995
const initMocks = () => {
90-
context = {
96+
const mockContext: Partial<BuilderContext> = {
9197
target: {
9298
configuration: 'production',
9399
project: PROJECT,
@@ -103,13 +109,12 @@ const initMocks = () => {
103109
logger: new logging.NullLogger(),
104110
workspaceRoot: 'cwd',
105111
addTeardown: _ => {},
106-
validateOptions: _ => Promise.resolve({} as any),
112+
validateOptions: <T extends JsonObject = JsonObject>(_options: JsonObject) => Promise.resolve({} as T),
107113
getBuilderNameForTarget: () => Promise.resolve(''),
108-
analytics: null as any,
109114
getTargetOptions: (_: Target) =>
110115
Promise.resolve({
111116
outputPath: 'dist/some-folder'
112-
}),
117+
} as JsonObject),
113118
reportProgress: (_: number, __?: number, ___?: string) => {},
114119
reportStatus: (_: string) => {},
115120
reportRunning: () => {},
@@ -119,7 +124,8 @@ const initMocks = () => {
119124
Promise.resolve({
120125
result: Promise.resolve(createBuilderOutputMock(true))
121126
} as BuilderRun)
122-
} as any;
127+
};
128+
context = mockContext as BuilderContext;
123129
};
124130

125131
const createBuilderOutputMock = (success: boolean): BuilderOutput => {

src/engine/engine.spec.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ describe('engine', () => {
8181
const options = {};
8282
const finalOptions = await engine.prepareOptions(options, logger);
8383

84-
expect(finalOptions.repo).toMatch(/angular-schule\/angular-cli-ghpages/);
84+
// EXCEPTION to testing philosophy: Use .toContain() here because the protocol
85+
// (SSH: [email protected]: vs HTTPS: https://github.com/) depends on developer's
86+
// git config. We only care that the correct repo is discovered.
87+
expect(finalOptions.repo).toContain('angular-schule/angular-cli-ghpages');
8588
});
8689

8790
describe('remote', () => {
@@ -126,4 +129,95 @@ describe('engine', () => {
126129
expect(finalOptions.nojekyll).toBe(true);
127130
});
128131
});
132+
133+
describe('run - dist folder validation', () => {
134+
const logger = new logging.NullLogger();
135+
136+
it('should throw error when dist folder does not exist', async () => {
137+
// This test proves the CRITICAL operator precedence bug was fixed
138+
// BUG: await !fse.pathExists(dir) - applies ! to Promise (always false, error NEVER thrown)
139+
// FIX: !(await fse.pathExists(dir)) - awaits first, then negates (error IS thrown)
140+
141+
// Mock gh-pages module
142+
jest.mock('gh-pages', () => ({
143+
clean: jest.fn(),
144+
publish: jest.fn()
145+
}));
146+
147+
const fse = require('fs-extra');
148+
jest.spyOn(fse, 'pathExists').mockResolvedValue(false);
149+
150+
const nonExistentDir = '/path/to/nonexistent/dir';
151+
const expectedErrorMessage = 'Dist folder does not exist. Check the dir --dir parameter or build the project first!';
152+
153+
await expect(
154+
engine.run(nonExistentDir, { dotfiles: true, notfound: true, nojekyll: true }, logger)
155+
).rejects.toThrow(expectedErrorMessage);
156+
157+
expect(fse.pathExists).toHaveBeenCalledWith(nonExistentDir);
158+
});
159+
});
160+
161+
describe('prepareOptions - user credentials warnings', () => {
162+
it('should warn when only name is set without email', async () => {
163+
const testLogger = new logging.Logger('test');
164+
const warnSpy = jest.spyOn(testLogger, 'warn');
165+
166+
const options = { name: 'John Doe' };
167+
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.';
168+
169+
await engine.prepareOptions(options, testLogger);
170+
171+
expect(warnSpy).toHaveBeenCalledWith(expectedWarning);
172+
});
173+
174+
it('should warn when only email is set without name', async () => {
175+
const testLogger = new logging.Logger('test');
176+
const warnSpy = jest.spyOn(testLogger, 'warn');
177+
178+
const options = { email: '[email protected]' };
179+
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.';
180+
181+
await engine.prepareOptions(options, testLogger);
182+
183+
expect(warnSpy).toHaveBeenCalledWith(expectedWarning);
184+
});
185+
186+
it('should NOT warn when both name and email are set', async () => {
187+
const testLogger = new logging.Logger('test');
188+
const warnSpy = jest.spyOn(testLogger, 'warn');
189+
190+
const options = { name: 'John Doe', email: '[email protected]' };
191+
192+
const finalOptions = await engine.prepareOptions(options, testLogger);
193+
194+
expect(finalOptions.user).toEqual({ name: 'John Doe', email: '[email protected]' });
195+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('name and --email must be set together'));
196+
});
197+
});
198+
199+
describe('prepareOptions - deprecated noSilent warning', () => {
200+
it('should warn when noSilent parameter is used', async () => {
201+
const testLogger = new logging.Logger('test');
202+
const warnSpy = jest.spyOn(testLogger, 'warn');
203+
204+
const options = { noSilent: true };
205+
const expectedWarning = 'The --no-silent parameter is deprecated and no longer needed. Verbose logging is now always enabled. This parameter will be ignored.';
206+
207+
await engine.prepareOptions(options, testLogger);
208+
209+
expect(warnSpy).toHaveBeenCalledWith(expectedWarning);
210+
});
211+
212+
it('should NOT warn when noSilent is not provided', async () => {
213+
const testLogger = new logging.Logger('test');
214+
const warnSpy = jest.spyOn(testLogger, 'warn');
215+
216+
const options = {};
217+
218+
await engine.prepareOptions(options, testLogger);
219+
220+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('no-silent'));
221+
});
222+
});
129223
});

src/engine/engine.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@ export async function prepareOptions(
6666
// see https://stackoverflow.com/a/39129886
6767
const util = require('util');
6868
let debuglog = util.debuglog;
69-
util.debuglog = set => {
69+
util.debuglog = (set: string) => {
7070
if (set === 'gh-pages') {
71-
return function () {
72-
let message = util.format.apply(util, arguments);
71+
return function (...args: unknown[]) {
72+
let message = util.format.apply(util, args);
7373
logger.info(message);
7474
};
7575
}
@@ -94,14 +94,26 @@ export async function prepareOptions(
9494
logger.info('Dry-run: No changes are applied at all.');
9595
}
9696

97-
// TODO: Add warning if only name OR only email is set (not both)
98-
// When user object is not created, gh-pages uses local/global git config
99-
// This might be confusing if user only sets one parameter
97+
// Warn if deprecated noSilent parameter is used
98+
if (origOptions.noSilent !== undefined) {
99+
logger.warn(
100+
'The --no-silent parameter is deprecated and no longer needed. ' +
101+
'Verbose logging is now always enabled. This parameter will be ignored.'
102+
);
103+
}
104+
105+
// Handle user credentials - warn if only one is set
100106
if (options.name && options.email) {
101107
options['user'] = {
102108
name: options.name,
103109
email: options.email
104110
};
111+
} else if (options.name || options.email) {
112+
logger.warn(
113+
'WARNING: Both --name and --email must be set together to configure git user. ' +
114+
(options.name ? 'Only --name is set.' : 'Only --email is set.') +
115+
' Git will use the local or global git config instead.'
116+
);
105117
}
106118

107119
if (process.env.TRAVIS) {
@@ -189,7 +201,10 @@ export async function prepareOptions(
189201
}
190202

191203
async function checkIfDistFolderExists(dir: string) {
192-
if (await !fse.pathExists(dir)) {
204+
// CRITICAL FIX: Operator precedence bug
205+
// WRONG: await !fse.pathExists(dir) - applies ! to Promise (always false)
206+
// RIGHT: !(await fse.pathExists(dir)) - awaits first, then negates boolean
207+
if (!(await fse.pathExists(dir))) {
193208
throw new Error(
194209
'Dist folder does not exist. Check the dir --dir parameter or build the project first!'
195210
);
@@ -333,7 +348,7 @@ async function publishViaGhPages(
333348
});
334349
}
335350

336-
async function getRemoteUrl(options) {
351+
async function getRemoteUrl(options: Schema & { git?: string; remote?: string }): Promise<string> {
337352
const git = new Git(process.cwd(), options.git);
338353
return await git.getRemoteUrl(options.remote);
339354
}

src/interfaces.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,42 @@
1+
/**
2+
* Options for gh-pages.publish()
3+
* Based on https://github.com/tschaub/gh-pages#options
4+
*/
5+
export interface PublishOptions {
6+
repo?: string;
7+
remote?: string;
8+
branch?: string;
9+
message?: string;
10+
user?: { name: string; email: string };
11+
dotfiles?: boolean;
12+
notfound?: boolean;
13+
nojekyll?: boolean;
14+
noDotfiles?: boolean;
15+
noNotfound?: boolean;
16+
noNojekyll?: boolean;
17+
cname?: string;
18+
add?: boolean;
19+
git?: string;
20+
dryRun?: boolean;
21+
[key: string]: unknown; // Allow additional gh-pages options
22+
}
23+
124
export interface GHPages {
2-
publish(dir: string, options: any, callback: (error: any) => void);
25+
publish(dir: string, options: PublishOptions, callback: (error: Error | null) => void): void;
326
clean?(): void;
427
}
528

29+
export interface ArchitectTarget {
30+
builder: string;
31+
options?: {
32+
outputPath?: string | { base?: string; browser?: string };
33+
[key: string]: unknown;
34+
};
35+
}
36+
637
export interface WorkspaceProject {
738
projectType?: string;
8-
architect?: Record<
9-
string,
10-
{ builder: string; options?: Record<string, any> }
11-
>;
39+
architect?: Record<string, ArchitectTarget>;
1240
}
1341

1442
export interface Workspace {
@@ -17,5 +45,8 @@ export interface Workspace {
1745

1846
export interface BuildTarget {
1947
name: string;
20-
options?: Record<string, any>;
48+
options?: {
49+
outputPath?: string | { base?: string; browser?: string };
50+
[key: string]: unknown;
51+
};
2152
}

src/parameter-tests/builder-integration.spec.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BuilderContext, BuilderRun, ScheduleOptions, Target } from '@angular-devkit/architect/src';
22
import { JsonObject, logging } from '@angular-devkit/core';
3-
import { BuildTarget } from '../interfaces';
3+
import { BuildTarget, PublishOptions } from '../interfaces';
44
import { Schema } from '../deploy/schema';
55

66
/**
@@ -16,25 +16,13 @@ import { Schema } from '../deploy/schema';
1616
* This ensures that parameter transformation from Angular Builder format
1717
* (noDotfiles, noNotfound, noNojekyll) to engine format (dotfiles, notfound, nojekyll)
1818
* works correctly in the complete execution flow.
19+
*
20+
* WHAT'S REAL vs MOCKED:
21+
* ✅ REAL: deploy/actions.ts, engine/engine.ts, prepareOptions()
22+
* ❌ MOCKED: gh-pages.publish() (to capture final options), fs-extra, gh-pages/lib/git
23+
* This IS a true integration test - we test the full internal code path with external dependencies mocked.
1924
*/
2025

21-
interface PublishOptions {
22-
repo?: string;
23-
remote?: string;
24-
branch?: string;
25-
message?: string;
26-
user?: { name: string; email: string };
27-
dotfiles?: boolean;
28-
notfound?: boolean;
29-
nojekyll?: boolean;
30-
noDotfiles?: boolean;
31-
noNotfound?: boolean;
32-
noNojekyll?: boolean;
33-
cname?: string;
34-
add?: boolean;
35-
git?: string;
36-
}
37-
3826
// Captured options from gh-pages.publish()
3927
let capturedPublishOptions: PublishOptions | null = null;
4028

0 commit comments

Comments
 (0)