@schilling.mark.a/software-methodology 1.0.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/.github/copilot-instructions.md +106 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/atdd-workflow/SKILL.md +117 -0
- package/atdd-workflow/references/green-phase.md +38 -0
- package/atdd-workflow/references/red-phase.md +62 -0
- package/atdd-workflow/references/refactor-phase.md +75 -0
- package/bdd-specification/SKILL.md +88 -0
- package/bdd-specification/references/example-mapping.md +105 -0
- package/bdd-specification/references/gherkin-patterns.md +214 -0
- package/cicd-pipeline/SKILL.md +64 -0
- package/cicd-pipeline/references/deployment-rollback.md +176 -0
- package/cicd-pipeline/references/environment-promotion.md +159 -0
- package/cicd-pipeline/references/pipeline-stages.md +198 -0
- package/clean-code/SKILL.md +77 -0
- package/clean-code/references/behavioral-patterns.md +329 -0
- package/clean-code/references/creational-patterns.md +197 -0
- package/clean-code/references/enterprise-patterns.md +334 -0
- package/clean-code/references/solid.md +230 -0
- package/clean-code/references/structural-patterns.md +238 -0
- package/continuous-improvement/SKILL.md +69 -0
- package/continuous-improvement/references/measurement.md +133 -0
- package/continuous-improvement/references/process-update.md +118 -0
- package/continuous-improvement/references/root-cause-analysis.md +144 -0
- package/dist/atdd-workflow.skill +0 -0
- package/dist/bdd-specification.skill +0 -0
- package/dist/cicd-pipeline.skill +0 -0
- package/dist/clean-code.skill +0 -0
- package/dist/continuous-improvement.skill +0 -0
- package/dist/green-implementation.skill +0 -0
- package/dist/product-strategy.skill +0 -0
- package/dist/story-mapping.skill +0 -0
- package/dist/ui-design-system.skill +0 -0
- package/dist/ui-design-workflow.skill +0 -0
- package/dist/ux-design.skill +0 -0
- package/dist/ux-research.skill +0 -0
- package/docs/INTEGRATION.md +229 -0
- package/docs/QUICKSTART.md +126 -0
- package/docs/SHARING.md +828 -0
- package/docs/SKILLS.md +296 -0
- package/green-implementation/SKILL.md +155 -0
- package/green-implementation/references/angular-patterns.md +239 -0
- package/green-implementation/references/common-rejections.md +180 -0
- package/green-implementation/references/playwright-patterns.md +321 -0
- package/green-implementation/references/rxjs-patterns.md +161 -0
- package/package.json +57 -0
- package/product-strategy/SKILL.md +71 -0
- package/product-strategy/references/business-model-canvas.md +199 -0
- package/product-strategy/references/canvas-alignment.md +108 -0
- package/product-strategy/references/value-proposition-canvas.md +159 -0
- package/project-templates/context.md.template +56 -0
- package/project-templates/test-strategy.md.template +87 -0
- package/story-mapping/SKILL.md +104 -0
- package/story-mapping/references/backbone.md +66 -0
- package/story-mapping/references/release-planning.md +92 -0
- package/story-mapping/references/task-template.md +78 -0
- package/story-mapping/references/walking-skeleton.md +63 -0
- package/ui-design-system/SKILL.md +48 -0
- package/ui-design-system/references/accessibility.md +134 -0
- package/ui-design-system/references/components.md +257 -0
- package/ui-design-system/references/design-tokens.md +209 -0
- package/ui-design-system/references/layout.md +136 -0
- package/ui-design-system/references/typography.md +114 -0
- package/ui-design-workflow/SKILL.md +90 -0
- package/ui-design-workflow/references/acceptance-targets.md +144 -0
- package/ui-design-workflow/references/component-selection.md +108 -0
- package/ui-design-workflow/references/scenario-to-ui.md +151 -0
- package/ui-design-workflow/references/screen-flows.md +116 -0
- package/ux-design/SKILL.md +75 -0
- package/ux-design/references/information-architecture.md +144 -0
- package/ux-design/references/interaction-patterns.md +141 -0
- package/ux-design/references/onboarding.md +159 -0
- package/ux-design/references/usability-evaluation.md +132 -0
- package/ux-research/SKILL.md +75 -0
- package/ux-research/references/journey-mapping.md +168 -0
- package/ux-research/references/mental-models.md +106 -0
- package/ux-research/references/personas.md +102 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Common PR Rejections
|
|
2
|
+
|
|
3
|
+
This file tracks patterns that repeatedly cause PR rejections. Each entry
|
|
4
|
+
comes from actual reviewer feedback captured via `record_pr_feedback` in the
|
|
5
|
+
MCP server. When a pattern appears 3+ times, it gets added here.
|
|
6
|
+
|
|
7
|
+
Update this file as part of the PDCA Act step. Every entry here should also
|
|
8
|
+
have a corresponding automated rule in `team-standards.json`.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Architecture Violations
|
|
13
|
+
|
|
14
|
+
### HttpClient in Components
|
|
15
|
+
|
|
16
|
+
**Frequency:** Very common
|
|
17
|
+
**Rule:** `arch-001`
|
|
18
|
+
|
|
19
|
+
Reviewers reject any component that imports or injects `HttpClient`.
|
|
20
|
+
All HTTP calls go through services.
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// ❌ Always rejected
|
|
24
|
+
import { HttpClient } from '@angular/common/http';
|
|
25
|
+
@Component({ ... })
|
|
26
|
+
export class MyComponent {
|
|
27
|
+
constructor(private http: HttpClient) {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ✅ Fix
|
|
31
|
+
@Component({ ... })
|
|
32
|
+
export class MyComponent {
|
|
33
|
+
constructor(private myService: MyService) {}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Business Logic in Component Templates
|
|
38
|
+
|
|
39
|
+
**Frequency:** Common
|
|
40
|
+
|
|
41
|
+
Complex expressions in templates are flagged. Move logic to
|
|
42
|
+
component properties or service methods.
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
// ❌ Rejected — logic in template
|
|
46
|
+
<div *ngIf="users.filter(u => u.role === 'admin' && u.active).length > 0">
|
|
47
|
+
|
|
48
|
+
// ✅ Fix — computed property
|
|
49
|
+
get hasActiveAdmins(): boolean {
|
|
50
|
+
return this.users.some(u => u.role === 'admin' && u.active);
|
|
51
|
+
}
|
|
52
|
+
// Template: *ngIf="hasActiveAdmins"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Naming
|
|
58
|
+
|
|
59
|
+
### Hungarian Notation
|
|
60
|
+
|
|
61
|
+
**Frequency:** Very common
|
|
62
|
+
**Rule:** `naming-001`
|
|
63
|
+
|
|
64
|
+
Team uses plain camelCase. Type prefixes (str, arr, int, bool, obj)
|
|
65
|
+
are always flagged.
|
|
66
|
+
|
|
67
|
+
### Missing Observable Suffix
|
|
68
|
+
|
|
69
|
+
**Frequency:** Common
|
|
70
|
+
**Rule:** `naming-002`
|
|
71
|
+
|
|
72
|
+
Any property typed as `Observable<T>` must end with `$`.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Error Handling
|
|
77
|
+
|
|
78
|
+
### Empty Catch Blocks
|
|
79
|
+
|
|
80
|
+
**Frequency:** Very common
|
|
81
|
+
**Rule:** `err-001`
|
|
82
|
+
|
|
83
|
+
Swallowing errors silently is always rejected. Log the error and
|
|
84
|
+
either re-throw or handle it meaningfully.
|
|
85
|
+
|
|
86
|
+
### switchMap Without catchError
|
|
87
|
+
|
|
88
|
+
**Frequency:** Common (added after PR feedback)
|
|
89
|
+
**Rule:** `rxjs-001` (if added)
|
|
90
|
+
|
|
91
|
+
Inner observables in switchMap that don't handle errors kill the
|
|
92
|
+
outer stream. Reviewers flag this because it causes silent failures
|
|
93
|
+
in production that are extremely hard to debug.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Testing
|
|
98
|
+
|
|
99
|
+
### Raw Selectors in Test Files
|
|
100
|
+
|
|
101
|
+
**Frequency:** Very common
|
|
102
|
+
**Rule:** `test-001`
|
|
103
|
+
|
|
104
|
+
Any `page.getByTestId()`, `page.locator()`, or `page.getByRole()` directly
|
|
105
|
+
in a `.spec.ts` file is rejected. All selectors must be in Page Object classes.
|
|
106
|
+
|
|
107
|
+
### Hard-Coded Test Credentials
|
|
108
|
+
|
|
109
|
+
**Frequency:** Common
|
|
110
|
+
**Rule:** `test-002`
|
|
111
|
+
|
|
112
|
+
Hard-coded email addresses, passwords, or API keys in test files are
|
|
113
|
+
always flagged. Use fixtures or test data builders.
|
|
114
|
+
|
|
115
|
+
### Manual Waits
|
|
116
|
+
|
|
117
|
+
**Frequency:** Common
|
|
118
|
+
**Rule:** `pw-001`
|
|
119
|
+
|
|
120
|
+
`page.waitForTimeout()` is always rejected. Use web-first assertions
|
|
121
|
+
that auto-wait.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Security
|
|
126
|
+
|
|
127
|
+
### console.log in Production Code
|
|
128
|
+
|
|
129
|
+
**Frequency:** Very common
|
|
130
|
+
**Rule:** `sec-001`
|
|
131
|
+
|
|
132
|
+
Any `console.log`, `console.warn`, or `console.error` in production
|
|
133
|
+
code (non-test files) is rejected. Use LoggerService.
|
|
134
|
+
|
|
135
|
+
### innerHTML Assignment
|
|
136
|
+
|
|
137
|
+
**Frequency:** Occasional
|
|
138
|
+
**Rule:** `sec-002`
|
|
139
|
+
|
|
140
|
+
Direct `element.innerHTML = ...` creates XSS vulnerabilities.
|
|
141
|
+
Use Angular's `[innerHTML]` binding with DomSanitizer.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Style
|
|
146
|
+
|
|
147
|
+
### Magic Numbers
|
|
148
|
+
|
|
149
|
+
**Frequency:** Common
|
|
150
|
+
**Rule:** `style-001`
|
|
151
|
+
|
|
152
|
+
Numeric literals other than 0, 1, -1 are flagged. Extract to
|
|
153
|
+
named constants.
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// ❌ Rejected
|
|
157
|
+
if (retries >= 3) { ... }
|
|
158
|
+
if (items.length > 50) { ... }
|
|
159
|
+
|
|
160
|
+
// ✅ Fix
|
|
161
|
+
const MAX_RETRIES = 3;
|
|
162
|
+
const MAX_DISPLAY_ITEMS = 50;
|
|
163
|
+
if (retries >= MAX_RETRIES) { ... }
|
|
164
|
+
if (items.length > MAX_DISPLAY_ITEMS) { ... }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Adding New Entries
|
|
170
|
+
|
|
171
|
+
When a PR is rejected for a pattern not listed here:
|
|
172
|
+
|
|
173
|
+
1. Call `record_pr_feedback` in the MCP server
|
|
174
|
+
2. Determine if the pattern can be caught by regex → `add_rule`
|
|
175
|
+
3. Add an entry here with:
|
|
176
|
+
- **Frequency** — how often this has been flagged
|
|
177
|
+
- **Rule** — the corresponding team-standards.json rule ID
|
|
178
|
+
- **✅/❌ examples** — concrete code showing right vs wrong
|
|
179
|
+
4. Update the corresponding section in SKILL.md if the summary
|
|
180
|
+
table doesn't already cover it
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# Playwright & Page Object Model Patterns
|
|
2
|
+
|
|
3
|
+
## Page Object Structure
|
|
4
|
+
|
|
5
|
+
### Base Page Object
|
|
6
|
+
|
|
7
|
+
Every page object extends a base class that provides common utilities.
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// tests/helpers/base.page.ts
|
|
11
|
+
import { type Page, type Locator, expect } from '@playwright/test';
|
|
12
|
+
|
|
13
|
+
export abstract class BasePage {
|
|
14
|
+
constructor(protected readonly page: Page) {}
|
|
15
|
+
|
|
16
|
+
/** Navigate to this page's URL */
|
|
17
|
+
abstract goto(): Promise<void>;
|
|
18
|
+
|
|
19
|
+
/** Assert the page loaded correctly */
|
|
20
|
+
abstract expectLoaded(): Promise<void>;
|
|
21
|
+
|
|
22
|
+
/** Common: wait for Angular stability */
|
|
23
|
+
protected async waitForAngular(): Promise<void> {
|
|
24
|
+
await this.page.waitForLoadState('networkidle');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Feature Page Object
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// tests/helpers/user-list.page.ts
|
|
33
|
+
import { type Page, type Locator, expect } from '@playwright/test';
|
|
34
|
+
import { BasePage } from './base.page';
|
|
35
|
+
|
|
36
|
+
export class UserListPage extends BasePage {
|
|
37
|
+
// ─── Locators (user-facing preferred) ──────────────
|
|
38
|
+
readonly heading = this.page.getByRole('heading', { name: 'Users' });
|
|
39
|
+
readonly searchInput = this.page.getByLabel('Search users');
|
|
40
|
+
readonly userRows = this.page.getByRole('row').filter({ hasNot: this.page.getByRole('columnheader') });
|
|
41
|
+
readonly addUserButton = this.page.getByRole('button', { name: 'Add user' });
|
|
42
|
+
readonly loadingSpinner = this.page.getByTestId('loading-spinner');
|
|
43
|
+
readonly emptyState = this.page.getByText('No users found');
|
|
44
|
+
readonly errorBanner = this.page.getByRole('alert');
|
|
45
|
+
readonly paginationNext = this.page.getByRole('button', { name: 'Next page' });
|
|
46
|
+
|
|
47
|
+
constructor(page: Page) {
|
|
48
|
+
super(page);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Navigation ────────────────────────────────────
|
|
52
|
+
async goto(): Promise<void> {
|
|
53
|
+
await this.page.goto('/users');
|
|
54
|
+
await this.waitForAngular();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async expectLoaded(): Promise<void> {
|
|
58
|
+
await expect(this.heading).toBeVisible();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Actions (named after user intent) ─────────────
|
|
62
|
+
async searchFor(term: string): Promise<void> {
|
|
63
|
+
await this.searchInput.fill(term);
|
|
64
|
+
// Wait for debounced search to complete
|
|
65
|
+
await this.page.waitForResponse(resp =>
|
|
66
|
+
resp.url().includes('/api/users') && resp.status() === 200
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async selectUser(name: string): Promise<void> {
|
|
71
|
+
await this.page.getByRole('row').filter({ hasText: name }).click();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async goToNextPage(): Promise<void> {
|
|
75
|
+
await this.paginationNext.click();
|
|
76
|
+
await this.waitForAngular();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Assertions (high-level, readable) ─────────────
|
|
80
|
+
async expectUserCount(count: number): Promise<void> {
|
|
81
|
+
await expect(this.userRows).toHaveCount(count);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async expectUserVisible(name: string): Promise<void> {
|
|
85
|
+
await expect(this.page.getByRole('row').filter({ hasText: name })).toBeVisible();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async expectEmptyState(): Promise<void> {
|
|
89
|
+
await expect(this.emptyState).toBeVisible();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async expectError(message: string): Promise<void> {
|
|
93
|
+
await expect(this.errorBanner).toContainText(message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Rules for Page Objects
|
|
99
|
+
|
|
100
|
+
1. **Locators are properties, not methods** — declare them in the class body
|
|
101
|
+
2. **Actions are named after user intent** — `searchFor()`, `selectUser()`, not `fillSearchInput()`
|
|
102
|
+
3. **Assertions belong in the PO** — `expectUserCount()`, not raw `expect()` in test files
|
|
103
|
+
4. **No test logic in POs** — no conditionals, no test data creation
|
|
104
|
+
5. **One PO per page/major section** — don't create POs for individual components unless reused across pages
|
|
105
|
+
|
|
106
|
+
## Test File Structure
|
|
107
|
+
|
|
108
|
+
### Tests Call Page Object Methods Only
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// tests/acceptance/users/user-search.spec.ts
|
|
112
|
+
import { test } from '@playwright/test';
|
|
113
|
+
import { UserListPage } from '../../helpers/user-list.page';
|
|
114
|
+
|
|
115
|
+
test.describe('User Search', () => {
|
|
116
|
+
let userList: UserListPage;
|
|
117
|
+
|
|
118
|
+
test.beforeEach(async ({ page }) => {
|
|
119
|
+
userList = new UserListPage(page);
|
|
120
|
+
await userList.goto();
|
|
121
|
+
await userList.expectLoaded();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('should filter users by name when searching', async () => {
|
|
125
|
+
// Given the user list is loaded (beforeEach)
|
|
126
|
+
|
|
127
|
+
// When I search for "Mark"
|
|
128
|
+
await userList.searchFor('Mark');
|
|
129
|
+
|
|
130
|
+
// Then I should see only matching users
|
|
131
|
+
await userList.expectUserVisible('Mark Schilling');
|
|
132
|
+
await userList.expectUserCount(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('should show empty state when no results match', async () => {
|
|
136
|
+
await userList.searchFor('zzzznonexistent');
|
|
137
|
+
await userList.expectEmptyState();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('should show error when search API fails', async ({ page }) => {
|
|
141
|
+
// Simulate API failure
|
|
142
|
+
await page.route('**/api/users**', route => route.abort());
|
|
143
|
+
await userList.searchFor('anything');
|
|
144
|
+
await userList.expectError('Search unavailable');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### What NEVER Appears in Test Files
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// ❌ Raw selectors in test file — move to Page Object
|
|
153
|
+
await page.getByTestId('search-input').fill('Mark');
|
|
154
|
+
await page.locator('.user-row').first().click();
|
|
155
|
+
|
|
156
|
+
// ❌ Manual waits — use web-first assertions
|
|
157
|
+
await page.waitForTimeout(2000);
|
|
158
|
+
|
|
159
|
+
// ❌ Hard-coded credentials
|
|
160
|
+
const email = 'admin@example.com';
|
|
161
|
+
const password = 'Secret123!';
|
|
162
|
+
|
|
163
|
+
// ❌ CSS/XPath selectors
|
|
164
|
+
await page.locator('#user-list > div:nth-child(2)').click();
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Locator Priority
|
|
168
|
+
|
|
169
|
+
Use the most user-facing locator available:
|
|
170
|
+
|
|
171
|
+
| Priority | Locator | When |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| 1st | `getByRole()` | Buttons, links, headings, lists, rows |
|
|
174
|
+
| 2nd | `getByLabel()` | Form inputs with labels |
|
|
175
|
+
| 3rd | `getByText()` | Visible text content |
|
|
176
|
+
| 4th | `getByPlaceholder()` | Inputs without visible labels |
|
|
177
|
+
| 5th | `getByTestId()` | No user-facing way to identify the element |
|
|
178
|
+
| Avoid | `locator('.class')` | Fragile — breaks on CSS changes |
|
|
179
|
+
| Never | `locator('#id')` or XPath | Extremely fragile |
|
|
180
|
+
|
|
181
|
+
## Assertions
|
|
182
|
+
|
|
183
|
+
### Web-First Only (Auto-Waiting)
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// ✅ These auto-wait for the condition to be true
|
|
187
|
+
await expect(locator).toBeVisible();
|
|
188
|
+
await expect(locator).toHaveText('Welcome');
|
|
189
|
+
await expect(locator).toHaveCount(5);
|
|
190
|
+
await expect(locator).toBeEnabled();
|
|
191
|
+
await expect(locator).toHaveAttribute('href', '/users');
|
|
192
|
+
await expect(page).toHaveURL(/\/dashboard/);
|
|
193
|
+
|
|
194
|
+
// ❌ NEVER use manual waits
|
|
195
|
+
await page.waitForTimeout(2000); // Arbitrary delay
|
|
196
|
+
await page.waitForSelector('.loaded'); // CSS selector
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Negative Assertions
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
// ✅ Assert something is NOT visible
|
|
203
|
+
await expect(locator).not.toBeVisible();
|
|
204
|
+
await expect(locator).toBeHidden();
|
|
205
|
+
|
|
206
|
+
// ✅ Assert count is zero
|
|
207
|
+
await expect(locator).toHaveCount(0);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Test Data
|
|
211
|
+
|
|
212
|
+
### Fixture Pattern
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
// tests/fixtures/user.fixtures.ts
|
|
216
|
+
import { type User } from '../../src/app/users/user.model';
|
|
217
|
+
|
|
218
|
+
export class UserFixtures {
|
|
219
|
+
static readonly VALID_USER: Readonly<User> = {
|
|
220
|
+
id: 'test-user-001',
|
|
221
|
+
name: 'Test User',
|
|
222
|
+
email: 'test@example.com',
|
|
223
|
+
role: 'viewer',
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
static readonly ADMIN_USER: Readonly<User> = {
|
|
227
|
+
id: 'test-admin-001',
|
|
228
|
+
name: 'Test Admin',
|
|
229
|
+
email: 'admin@example.com',
|
|
230
|
+
role: 'admin',
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
static createUser(overrides: Partial<User> = {}): User {
|
|
234
|
+
return {
|
|
235
|
+
...UserFixtures.VALID_USER,
|
|
236
|
+
id: `test-${Date.now()}`,
|
|
237
|
+
...overrides,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### API Mocking for Deterministic Tests
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// ✅ Mock API responses for predictable test data
|
|
247
|
+
test.beforeEach(async ({ page }) => {
|
|
248
|
+
await page.route('**/api/users', route =>
|
|
249
|
+
route.fulfill({
|
|
250
|
+
status: 200,
|
|
251
|
+
contentType: 'application/json',
|
|
252
|
+
body: JSON.stringify([
|
|
253
|
+
UserFixtures.VALID_USER,
|
|
254
|
+
UserFixtures.ADMIN_USER,
|
|
255
|
+
]),
|
|
256
|
+
})
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Feature Flags (LaunchDarkly)
|
|
262
|
+
|
|
263
|
+
### Testing Feature Flag Variants
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
// tests/helpers/feature-flags.ts
|
|
267
|
+
export async function setFeatureFlag(
|
|
268
|
+
page: Page,
|
|
269
|
+
flagKey: string,
|
|
270
|
+
value: boolean | string | number
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
await page.route('**/sdk/evalx/**', async route => {
|
|
273
|
+
const response = await route.fetch();
|
|
274
|
+
const body = await response.json();
|
|
275
|
+
body[flagKey] = { ...body[flagKey], value };
|
|
276
|
+
await route.fulfill({ response, body: JSON.stringify(body) });
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// In test
|
|
281
|
+
test('should show new dashboard when feature flag enabled', async ({ page }) => {
|
|
282
|
+
await setFeatureFlag(page, 'new-dashboard', true);
|
|
283
|
+
const dashboard = new DashboardPage(page);
|
|
284
|
+
await dashboard.goto();
|
|
285
|
+
await dashboard.expectNewLayoutVisible();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('should show legacy dashboard when feature flag disabled', async ({ page }) => {
|
|
289
|
+
await setFeatureFlag(page, 'new-dashboard', false);
|
|
290
|
+
const dashboard = new DashboardPage(page);
|
|
291
|
+
await dashboard.goto();
|
|
292
|
+
await dashboard.expectLegacyLayoutVisible();
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## File Organization
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
tests/
|
|
300
|
+
├── acceptance/ # Outer loop — one dir per domain
|
|
301
|
+
│ ├── users/
|
|
302
|
+
│ │ ├── user-list.spec.ts
|
|
303
|
+
│ │ ├── user-search.spec.ts
|
|
304
|
+
│ │ └── user-create.spec.ts
|
|
305
|
+
│ └── auth/
|
|
306
|
+
│ ├── login.spec.ts
|
|
307
|
+
│ └── logout.spec.ts
|
|
308
|
+
├── unit/ # Inner loop — mirrors src/ structure
|
|
309
|
+
│ └── users/
|
|
310
|
+
│ ├── user.service.spec.ts
|
|
311
|
+
│ └── user.model.spec.ts
|
|
312
|
+
├── fixtures/ # Shared test data
|
|
313
|
+
│ ├── user.fixtures.ts
|
|
314
|
+
│ └── auth.fixtures.ts
|
|
315
|
+
└── helpers/ # Page objects + utilities
|
|
316
|
+
├── base.page.ts
|
|
317
|
+
├── user-list.page.ts
|
|
318
|
+
├── login.page.ts
|
|
319
|
+
├── feature-flags.ts
|
|
320
|
+
└── test-data-builder.ts
|
|
321
|
+
```
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# RxJS Implementation Patterns
|
|
2
|
+
|
|
3
|
+
## Operator Safety Rules
|
|
4
|
+
|
|
5
|
+
### switchMap Must Have catchError
|
|
6
|
+
|
|
7
|
+
Every `switchMap` creates an inner observable. If that inner observable errors
|
|
8
|
+
without a `catchError`, the entire outer stream dies silently.
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
// ✅ CORRECT — error handled inside switchMap
|
|
12
|
+
this.searchTerm$.pipe(
|
|
13
|
+
debounceTime(300),
|
|
14
|
+
distinctUntilChanged(),
|
|
15
|
+
switchMap(term =>
|
|
16
|
+
this.searchService.search(term).pipe(
|
|
17
|
+
catchError(error => {
|
|
18
|
+
this.logger.error('Search failed', { term, error });
|
|
19
|
+
return of({ results: [], error: 'Search unavailable' });
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// ❌ REJECTED — inner observable error kills the stream
|
|
26
|
+
this.searchTerm$.pipe(
|
|
27
|
+
debounceTime(300),
|
|
28
|
+
switchMap(term => this.searchService.search(term))
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This applies to all higher-order mapping operators:
|
|
33
|
+
`switchMap`, `mergeMap`, `concatMap`, `exhaustMap`.
|
|
34
|
+
|
|
35
|
+
### Prefer async/await Over .then() Chains
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
// ✅ CORRECT
|
|
39
|
+
async onSubmit(): Promise<void> {
|
|
40
|
+
try {
|
|
41
|
+
const result = await firstValueFrom(this.userService.saveUser(this.form.value));
|
|
42
|
+
this.router.navigate(['/users', result.id]);
|
|
43
|
+
} catch (error: unknown) {
|
|
44
|
+
this.handleError(error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ❌ REJECTED — .then() chains
|
|
49
|
+
onSubmit(): void {
|
|
50
|
+
this.userService.saveUser(this.form.value)
|
|
51
|
+
.toPromise()
|
|
52
|
+
.then(result => this.router.navigate(['/users', result.id]))
|
|
53
|
+
.then(() => this.notification.show('Saved'))
|
|
54
|
+
.catch(error => this.handleError(error));
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Exception: when working inside a purely reactive pipeline (e.g., a store effect
|
|
59
|
+
or an interceptor), use operators like `tap` and `catchError` instead of
|
|
60
|
+
async/await. The rule is about component/service methods that call observables,
|
|
61
|
+
not about replacing operators inside pipes.
|
|
62
|
+
|
|
63
|
+
## Common Reactive Patterns
|
|
64
|
+
|
|
65
|
+
### Combining Multiple Data Sources
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// ✅ combineLatest — emits when any source changes
|
|
69
|
+
readonly viewModel$ = combineLatest([
|
|
70
|
+
this.userService.getUsers(),
|
|
71
|
+
this.filterService.activeFilter$,
|
|
72
|
+
this.sortService.activeSort$,
|
|
73
|
+
]).pipe(
|
|
74
|
+
map(([users, filter, sort]) => ({
|
|
75
|
+
users: this.applyFilter(users, filter),
|
|
76
|
+
filter,
|
|
77
|
+
sort,
|
|
78
|
+
count: users.length,
|
|
79
|
+
}))
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Loading State Pattern
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// ✅ Track loading, error, and data in one stream
|
|
87
|
+
readonly state$ = this.loadTrigger$.pipe(
|
|
88
|
+
switchMap(() =>
|
|
89
|
+
this.userService.getUsers().pipe(
|
|
90
|
+
map(users => ({ loading: false, error: null, users })),
|
|
91
|
+
catchError(error => of({ loading: false, error: error.message, users: [] as User[] })),
|
|
92
|
+
startWith({ loading: true, error: null, users: [] as User[] })
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Form Value Changes
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// ✅ Debounced reactive form search
|
|
102
|
+
this.searchControl.valueChanges.pipe(
|
|
103
|
+
debounceTime(300),
|
|
104
|
+
distinctUntilChanged(),
|
|
105
|
+
filter((term): term is string => term !== null && term.length >= 2),
|
|
106
|
+
switchMap(term =>
|
|
107
|
+
this.searchService.search(term).pipe(
|
|
108
|
+
catchError(() => of([]))
|
|
109
|
+
)
|
|
110
|
+
),
|
|
111
|
+
takeUntilDestroyed(this.destroyRef)
|
|
112
|
+
).subscribe(results => this.results = results);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Operators to Prefer
|
|
116
|
+
|
|
117
|
+
| Situation | Use | Avoid |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| Cancel previous on new emission | `switchMap` | `mergeMap` (causes race conditions) |
|
|
120
|
+
| Queue sequential requests | `concatMap` | `mergeMap` (unordered) |
|
|
121
|
+
| Ignore while processing | `exhaustMap` | `switchMap` (cancels work) |
|
|
122
|
+
| Wait for first value then complete | `firstValueFrom()` | `.toPromise()` (deprecated) |
|
|
123
|
+
| Take one value | `first()` or `take(1)` | Subscribing without unsubscribe |
|
|
124
|
+
| Side effects in a pipe | `tap()` | Nested subscribes |
|
|
125
|
+
|
|
126
|
+
## Anti-Patterns
|
|
127
|
+
|
|
128
|
+
### Never Nest Subscribes
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// ❌ Nested subscribe — callback hell, memory leaks
|
|
132
|
+
this.route.params.subscribe(params => {
|
|
133
|
+
this.userService.getUserById(params['id']).subscribe(user => {
|
|
134
|
+
this.roleService.getRoles(user.roleId).subscribe(roles => {
|
|
135
|
+
this.roles = roles;
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ✅ Flatten with operators
|
|
141
|
+
this.route.params.pipe(
|
|
142
|
+
switchMap(params => this.userService.getUserById(params['id'])),
|
|
143
|
+
switchMap(user => this.roleService.getRoles(user.roleId).pipe(
|
|
144
|
+
catchError(() => of([]))
|
|
145
|
+
)),
|
|
146
|
+
takeUntilDestroyed(this.destroyRef)
|
|
147
|
+
).subscribe(roles => this.roles = roles);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Never Subscribe Just to Assign
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// ❌ Subscribe to assign — use async pipe instead
|
|
154
|
+
ngOnInit() {
|
|
155
|
+
this.userService.getUsers().subscribe(users => this.users = users);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ✅ Let Angular manage the subscription
|
|
159
|
+
users$ = this.userService.getUsers();
|
|
160
|
+
// Template: *ngIf="users$ | async as users"
|
|
161
|
+
```
|