specweave 0.23.18 → 0.24.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/.claude-plugin/marketplace.json +93 -49
- package/CLAUDE.md +137 -4
- package/dist/src/cli/helpers/ado-area-path-mapper.d.ts +89 -0
- package/dist/src/cli/helpers/ado-area-path-mapper.d.ts.map +1 -0
- package/dist/src/cli/helpers/ado-area-path-mapper.js +213 -0
- package/dist/src/cli/helpers/ado-area-path-mapper.js.map +1 -0
- package/dist/src/cli/helpers/issue-tracker/ado-auto-discover.d.ts +29 -0
- package/dist/src/cli/helpers/issue-tracker/ado-auto-discover.d.ts.map +1 -0
- package/dist/src/cli/helpers/issue-tracker/ado-auto-discover.js +109 -0
- package/dist/src/cli/helpers/issue-tracker/ado-auto-discover.js.map +1 -0
- package/dist/src/cli/helpers/issue-tracker/ado.d.ts +1 -0
- package/dist/src/cli/helpers/issue-tracker/ado.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/ado.js +2 -0
- package/dist/src/cli/helpers/issue-tracker/ado.js.map +1 -1
- package/dist/src/cli/helpers/smart-filter.d.ts +83 -0
- package/dist/src/cli/helpers/smart-filter.d.ts.map +1 -0
- package/dist/src/cli/helpers/smart-filter.js +265 -0
- package/dist/src/cli/helpers/smart-filter.js.map +1 -0
- package/dist/src/core/qa/quality-gate-decider.d.ts +1 -1
- package/dist/src/core/qa/quality-gate-decider.js +2 -2
- package/dist/src/core/qa/quality-gate-decider.js.map +1 -1
- package/dist/src/core/qa/risk-calculator.d.ts +2 -2
- package/dist/src/core/qa/risk-calculator.js +2 -2
- package/dist/src/core/validators/ac-presence-validator.d.ts +56 -0
- package/dist/src/core/validators/ac-presence-validator.d.ts.map +1 -0
- package/dist/src/core/validators/ac-presence-validator.js +149 -0
- package/dist/src/core/validators/ac-presence-validator.js.map +1 -0
- package/dist/src/integrations/ado/area-path-mapper.d.ts +137 -0
- package/dist/src/integrations/ado/area-path-mapper.d.ts.map +1 -0
- package/dist/src/integrations/ado/area-path-mapper.js +267 -0
- package/dist/src/integrations/ado/area-path-mapper.js.map +1 -0
- package/dist/src/integrations/jira/filter-processor.d.ts +126 -0
- package/dist/src/integrations/jira/filter-processor.d.ts.map +1 -0
- package/dist/src/integrations/jira/filter-processor.js +207 -0
- package/dist/src/integrations/jira/filter-processor.js.map +1 -0
- package/dist/src/integrations/jira/jira-client.d.ts +13 -0
- package/dist/src/integrations/jira/jira-client.d.ts.map +1 -1
- package/dist/src/integrations/jira/jira-client.js +33 -0
- package/dist/src/integrations/jira/jira-client.js.map +1 -1
- package/dist/src/utils/ac-embedder.d.ts +63 -0
- package/dist/src/utils/ac-embedder.d.ts.map +1 -0
- package/dist/src/utils/ac-embedder.js +217 -0
- package/dist/src/utils/ac-embedder.js.map +1 -0
- package/dist/src/utils/env-manager.d.ts +86 -0
- package/dist/src/utils/env-manager.d.ts.map +1 -0
- package/dist/src/utils/env-manager.js +188 -0
- package/dist/src/utils/env-manager.js.map +1 -0
- package/package.json +1 -1
- package/plugins/specweave/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave/agents/AGENTS-INDEX.md +1 -1
- package/plugins/specweave/agents/increment-quality-judge-v2/AGENT.md +9 -9
- package/plugins/specweave/commands/specweave-do.md +37 -0
- package/plugins/specweave/commands/specweave-done.md +159 -0
- package/plugins/specweave/commands/specweave-embed-acs.md +446 -0
- package/plugins/specweave/commands/specweave-next.md +148 -3
- package/plugins/specweave/commands/specweave-qa.md +2 -2
- package/plugins/specweave/hooks/pre-increment-start.sh +168 -0
- package/plugins/specweave/skills/SKILLS-INDEX.md +1 -1
- package/plugins/specweave-ado/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-ado/commands/specweave-ado-import-projects.md +331 -0
- package/plugins/specweave-alternatives/.claude-plugin/plugin.json +10 -0
- package/plugins/specweave-alternatives/commands/alternatives-analyze.md +336 -0
- package/plugins/specweave-alternatives/skills/architecture-alternatives/SKILL.md +651 -0
- package/plugins/specweave-alternatives/skills/bmad-method/SKILL.md +420 -0
- package/plugins/specweave-alternatives/skills/spec-kit-expert/SKILL.md +487 -0
- package/plugins/specweave-backend/commands/api-scaffold.md +80 -0
- package/plugins/specweave-backend/commands/crud-generate.md +109 -0
- package/plugins/specweave-backend/commands/migration-generate.md +139 -0
- package/plugins/specweave-confluent/commands/connector-deploy.md +154 -0
- package/plugins/specweave-confluent/commands/ksqldb-query.md +179 -0
- package/plugins/specweave-confluent/commands/schema-register.md +123 -0
- package/plugins/specweave-core/.claude-plugin/plugin.json +21 -0
- package/plugins/specweave-core/commands/architecture-review.md +288 -0
- package/plugins/specweave-core/commands/code-review.md +213 -0
- package/plugins/specweave-core/commands/refactor-plan.md +249 -0
- package/plugins/specweave-core/skills/code-quality/SKILL.md +157 -0
- package/plugins/specweave-core/skills/design-patterns/SKILL.md +244 -0
- package/plugins/specweave-core/skills/software-architecture/SKILL.md +83 -0
- package/plugins/specweave-cost-optimizer/.claude-plugin/plugin.json +22 -0
- package/plugins/specweave-cost-optimizer/commands/cost-analyze.md +360 -0
- package/plugins/specweave-cost-optimizer/commands/cost-optimize.md +480 -0
- package/plugins/specweave-cost-optimizer/skills/aws-cost-expert/SKILL.md +416 -0
- package/plugins/specweave-cost-optimizer/skills/cloud-pricing/SKILL.md +325 -0
- package/plugins/specweave-cost-optimizer/skills/cost-optimization/SKILL.md +337 -0
- package/plugins/specweave-diagrams/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-diagrams/commands/diagrams-generate.md +168 -0
- package/plugins/specweave-docs/.claude-plugin/plugin.json +10 -0
- package/plugins/specweave-docs/commands/docs-generate.md +441 -0
- package/plugins/specweave-docs/commands/docs-init.md +334 -0
- package/plugins/specweave-docs/skills/docusaurus/SKILL.md +581 -0
- package/plugins/specweave-docs/skills/spec-driven-brainstorming/SKILL.md +689 -0
- package/plugins/specweave-docs/skills/technical-writing/SKILL.md +1039 -0
- package/plugins/specweave-docs-preview/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-figma/.claude-plugin/plugin.json +23 -0
- package/plugins/specweave-figma/commands/figma-import.md +690 -0
- package/plugins/specweave-figma/commands/figma-to-react.md +834 -0
- package/plugins/specweave-figma/commands/figma-tokens.md +815 -0
- package/plugins/specweave-frontend/.claude-plugin/plugin.json +21 -0
- package/plugins/specweave-frontend/agents/frontend-architect/AGENT.md +387 -0
- package/plugins/specweave-frontend/agents/frontend-architect/README.md +385 -0
- package/plugins/specweave-frontend/agents/frontend-architect/examples.md +590 -0
- package/plugins/specweave-frontend/agents/frontend-architect/templates/component-template.tsx +152 -0
- package/plugins/specweave-frontend/agents/frontend-architect/templates/hook-template.ts +311 -0
- package/plugins/specweave-frontend/agents/frontend-architect/templates/page-template.tsx +228 -0
- package/plugins/specweave-frontend/commands/component-generate.md +510 -0
- package/plugins/specweave-frontend/commands/design-system-init.md +494 -0
- package/plugins/specweave-frontend/commands/frontend-scaffold.md +207 -0
- package/plugins/specweave-frontend/commands/nextjs-setup.md +396 -0
- package/plugins/specweave-frontend/skills/design-system-architect/SKILL.md +278 -0
- package/plugins/specweave-frontend/skills/frontend/SKILL.md +420 -0
- package/plugins/specweave-frontend/skills/nextjs/SKILL.md +546 -0
- package/plugins/specweave-github/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-github/hooks/.specweave/logs/hooks-debug.log +194 -0
- package/plugins/specweave-infrastructure/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-jira/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-jira/commands/import-projects.js +183 -0
- package/plugins/specweave-jira/commands/import-projects.md +97 -0
- package/plugins/specweave-jira/commands/import-projects.ts +288 -0
- package/plugins/specweave-jira/commands/specweave-jira-import-projects.md +298 -0
- package/plugins/specweave-kafka/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-kafka-streams/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-kubernetes/commands/cluster-setup.md +262 -0
- package/plugins/specweave-kubernetes/commands/deployment-generate.md +242 -0
- package/plugins/specweave-kubernetes/commands/helm-scaffold.md +333 -0
- package/plugins/specweave-ml/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-mobile/commands/app-scaffold.md +233 -0
- package/plugins/specweave-mobile/commands/build-config.md +256 -0
- package/plugins/specweave-mobile/commands/screen-generate.md +289 -0
- package/plugins/specweave-n8n/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-plugin-dev/.claude-plugin/plugin.json +13 -12
- package/plugins/specweave-plugin-dev/commands/plugin-create.md +333 -0
- package/plugins/specweave-plugin-dev/commands/plugin-publish.md +339 -0
- package/plugins/specweave-plugin-dev/commands/plugin-test.md +293 -0
- package/plugins/specweave-plugin-dev/skills/claude-sdk/SKILL.md +162 -0
- package/plugins/specweave-plugin-dev/skills/marketplace-publishing/SKILL.md +263 -0
- package/plugins/specweave-plugin-dev/skills/plugin-development/SKILL.md +316 -0
- package/plugins/specweave-release/.claude-plugin/plugin.json +1 -1
- package/plugins/specweave-release/commands/specweave-release-npm.md +110 -0
- package/plugins/specweave-release/hooks/.specweave/logs/dora-tracking.log +168 -0
- package/plugins/specweave-testing/.claude-plugin/plugin.json +21 -0
- package/plugins/specweave-testing/agents/qa-engineer/AGENT.md +797 -0
- package/plugins/specweave-testing/agents/qa-engineer/README.md +443 -0
- package/plugins/specweave-testing/agents/qa-engineer/templates/playwright-e2e-test.ts +470 -0
- package/plugins/specweave-testing/agents/qa-engineer/templates/test-data-factory.ts +507 -0
- package/plugins/specweave-testing/agents/qa-engineer/templates/vitest-unit-test.ts +400 -0
- package/plugins/specweave-testing/agents/qa-engineer/test-strategies.md +726 -0
- package/plugins/specweave-testing/commands/e2e-setup.md +1081 -0
- package/plugins/specweave-testing/commands/test-coverage.md +979 -0
- package/plugins/specweave-testing/commands/test-generate.md +1156 -0
- package/plugins/specweave-testing/commands/test-init.md +409 -0
- package/plugins/specweave-testing/skills/e2e-playwright/SKILL.md +769 -0
- package/plugins/specweave-testing/skills/tdd-expert/SKILL.md +934 -0
- package/plugins/specweave-testing/skills/unit-testing-expert/SKILL.md +1011 -0
- package/plugins/specweave-tooling/.claude-plugin/plugin.json +22 -0
- package/plugins/specweave-tooling/commands/specweave-tooling-skill-create.md +691 -0
- package/plugins/specweave-tooling/commands/specweave-tooling-skill-package.md +751 -0
- package/plugins/specweave-tooling/commands/specweave-tooling-skill-validate.md +858 -0
- package/plugins/specweave-ui/.claude-plugin/plugin.json +10 -0
- package/plugins/specweave-ui/commands/ui-automate.md +199 -0
- package/plugins/specweave-ui/commands/ui-inspect.md +70 -0
- package/plugins/specweave-ui/skills/browser-automation/SKILL.md +314 -0
- package/plugins/specweave-ui/skills/ui-testing/SKILL.md +716 -0
- package/plugins/specweave-ui/skills/visual-regression/SKILL.md +728 -0
- package/plugins/specweave/commands/check-hooks.md +0 -257
- package/plugins/specweave/commands/specweave-archive-increments.md +0 -82
- package/plugins/specweave-plugin-dev/skills/plugin-expert/SKILL.md +0 -1231
- /package/plugins/specweave/{agents/code-reviewer.md → skills/code-reviewer/SKILL.md} +0 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
# QA Engineer Agent - Test Strategies
|
|
2
|
+
|
|
3
|
+
Comprehensive guide to testing strategies for different application types and scenarios.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
1. [Testing Pyramid Strategy](#testing-pyramid-strategy)
|
|
8
|
+
2. [Testing Trophy Strategy](#testing-trophy-strategy)
|
|
9
|
+
3. [TDD Red-Green-Refactor](#tdd-red-green-refactor)
|
|
10
|
+
4. [BDD Given-When-Then](#bdd-given-when-then)
|
|
11
|
+
5. [API Testing Strategy](#api-testing-strategy)
|
|
12
|
+
6. [Frontend Testing Strategy](#frontend-testing-strategy)
|
|
13
|
+
7. [Micro-Services Testing Strategy](#micro-services-testing-strategy)
|
|
14
|
+
8. [Performance Testing Strategy](#performance-testing-strategy)
|
|
15
|
+
9. [Security Testing Strategy](#security-testing-strategy)
|
|
16
|
+
10. [Accessibility Testing Strategy](#accessibility-testing-strategy)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Testing Pyramid Strategy
|
|
21
|
+
|
|
22
|
+
### Overview
|
|
23
|
+
|
|
24
|
+
The Testing Pyramid emphasizes a broad base of fast, cheap unit tests, fewer integration tests, and minimal UI/E2E tests.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
/\
|
|
28
|
+
/ \ E2E (10%)
|
|
29
|
+
/----\
|
|
30
|
+
/ \ Integration (20%)
|
|
31
|
+
/--------\
|
|
32
|
+
/ \ Unit (70%)
|
|
33
|
+
/--------------\
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Distribution
|
|
37
|
+
|
|
38
|
+
- **Unit Tests (70%)**: Test individual functions, classes, components in isolation
|
|
39
|
+
- **Integration Tests (20%)**: Test interactions between modules, APIs, databases
|
|
40
|
+
- **E2E Tests (10%)**: Test complete user journeys through the UI
|
|
41
|
+
|
|
42
|
+
### When to Use
|
|
43
|
+
|
|
44
|
+
- Traditional applications with clear layers
|
|
45
|
+
- Backend services with business logic
|
|
46
|
+
- Applications where unit tests provide high confidence
|
|
47
|
+
- Teams prioritizing fast feedback loops
|
|
48
|
+
|
|
49
|
+
### Implementation Example
|
|
50
|
+
|
|
51
|
+
**Unit Test (70% of suite)**:
|
|
52
|
+
```typescript
|
|
53
|
+
// src/utils/cart.test.ts
|
|
54
|
+
import { describe, it, expect } from 'vitest';
|
|
55
|
+
import { calculateTotal, applyDiscount } from './cart';
|
|
56
|
+
|
|
57
|
+
describe('Cart Utils', () => {
|
|
58
|
+
describe('calculateTotal', () => {
|
|
59
|
+
it('should sum item prices', () => {
|
|
60
|
+
const items = [
|
|
61
|
+
{ id: 1, price: 10 },
|
|
62
|
+
{ id: 2, price: 20 },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
expect(calculateTotal(items)).toBe(30);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should return 0 for empty cart', () => {
|
|
69
|
+
expect(calculateTotal([])).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should handle decimal prices', () => {
|
|
73
|
+
const items = [
|
|
74
|
+
{ id: 1, price: 10.99 },
|
|
75
|
+
{ id: 2, price: 20.50 },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
expect(calculateTotal(items)).toBeCloseTo(31.49, 2);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('applyDiscount', () => {
|
|
83
|
+
it('should apply percentage discount', () => {
|
|
84
|
+
expect(applyDiscount(100, 'SAVE20', { type: 'percentage', value: 20 })).toBe(80);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should apply fixed discount', () => {
|
|
88
|
+
expect(applyDiscount(100, 'SAVE10', { type: 'fixed', value: 10 })).toBe(90);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should not go below zero', () => {
|
|
92
|
+
expect(applyDiscount(50, 'SAVE100', { type: 'fixed', value: 100 })).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Integration Test (20% of suite)**:
|
|
99
|
+
```typescript
|
|
100
|
+
// src/api/orders.integration.test.ts
|
|
101
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
102
|
+
import { createTestServer } from '../test-utils/server';
|
|
103
|
+
import { seedDatabase, clearDatabase } from '../test-utils/database';
|
|
104
|
+
|
|
105
|
+
describe('Orders API Integration', () => {
|
|
106
|
+
let server: TestServer;
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
server = await createTestServer();
|
|
110
|
+
await seedDatabase();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
await clearDatabase();
|
|
115
|
+
await server.close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should create order and store in database', async () => {
|
|
119
|
+
const response = await server.request
|
|
120
|
+
.post('/api/orders')
|
|
121
|
+
.send({
|
|
122
|
+
userId: 'user-123',
|
|
123
|
+
items: [{ productId: 'prod-1', quantity: 2 }],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(response.status).toBe(201);
|
|
127
|
+
expect(response.body).toMatchObject({
|
|
128
|
+
id: expect.any(String),
|
|
129
|
+
userId: 'user-123',
|
|
130
|
+
status: 'pending',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Verify database persistence
|
|
134
|
+
const order = await server.db.orders.findById(response.body.id);
|
|
135
|
+
expect(order).toBeTruthy();
|
|
136
|
+
expect(order.items).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should send confirmation email on order creation', async () => {
|
|
140
|
+
const emailSpy = vi.spyOn(server.emailService, 'send');
|
|
141
|
+
|
|
142
|
+
await server.request
|
|
143
|
+
.post('/api/orders')
|
|
144
|
+
.send({
|
|
145
|
+
userId: 'user-123',
|
|
146
|
+
items: [{ productId: 'prod-1', quantity: 1 }],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(emailSpy).toHaveBeenCalledWith({
|
|
150
|
+
to: 'user@example.com',
|
|
151
|
+
subject: 'Order Confirmation',
|
|
152
|
+
template: 'order-confirmation',
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**E2E Test (10% of suite)**:
|
|
159
|
+
```typescript
|
|
160
|
+
// e2e/checkout-flow.spec.ts
|
|
161
|
+
import { test, expect } from '@playwright/test';
|
|
162
|
+
|
|
163
|
+
test.describe('Checkout Flow', () => {
|
|
164
|
+
test('should complete purchase as guest user', async ({ page }) => {
|
|
165
|
+
// Navigate to product
|
|
166
|
+
await page.goto('/products/laptop-123');
|
|
167
|
+
await expect(page.getByRole('heading', { name: 'Gaming Laptop' })).toBeVisible();
|
|
168
|
+
|
|
169
|
+
// Add to cart
|
|
170
|
+
await page.getByRole('button', { name: 'Add to Cart' }).click();
|
|
171
|
+
await expect(page.getByText('Item added to cart')).toBeVisible();
|
|
172
|
+
|
|
173
|
+
// Go to checkout
|
|
174
|
+
await page.getByRole('link', { name: 'Cart (1)' }).click();
|
|
175
|
+
await page.getByRole('button', { name: 'Checkout' }).click();
|
|
176
|
+
|
|
177
|
+
// Fill shipping info
|
|
178
|
+
await page.getByLabel('Email').fill('guest@example.com');
|
|
179
|
+
await page.getByLabel('Full Name').fill('John Doe');
|
|
180
|
+
await page.getByLabel('Address').fill('123 Main St');
|
|
181
|
+
await page.getByLabel('City').fill('New York');
|
|
182
|
+
await page.getByLabel('Zip Code').fill('10001');
|
|
183
|
+
|
|
184
|
+
// Fill payment info (test mode)
|
|
185
|
+
await page.getByLabel('Card Number').fill('4242424242424242');
|
|
186
|
+
await page.getByLabel('Expiry Date').fill('12/25');
|
|
187
|
+
await page.getByLabel('CVC').fill('123');
|
|
188
|
+
|
|
189
|
+
// Submit order
|
|
190
|
+
await page.getByRole('button', { name: 'Place Order' }).click();
|
|
191
|
+
|
|
192
|
+
// Verify confirmation
|
|
193
|
+
await expect(page).toHaveURL(/\/order-confirmation/);
|
|
194
|
+
await expect(page.getByText('Order Confirmed!')).toBeVisible();
|
|
195
|
+
await expect(page.getByText(/Order #/)).toBeVisible();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Coverage Targets
|
|
201
|
+
|
|
202
|
+
- **Unit Tests**: 80%+ line coverage, 75%+ branch coverage
|
|
203
|
+
- **Integration Tests**: 100% critical API endpoints
|
|
204
|
+
- **E2E Tests**: 100% critical user journeys
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Testing Trophy Strategy
|
|
209
|
+
|
|
210
|
+
### Overview
|
|
211
|
+
|
|
212
|
+
Modern approach that emphasizes integration tests over unit tests, with static analysis as the foundation.
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
/\
|
|
216
|
+
/ \ E2E (5%)
|
|
217
|
+
/----\
|
|
218
|
+
/ \ Integration (50%)
|
|
219
|
+
/--------\
|
|
220
|
+
/ \ Unit (25%)
|
|
221
|
+
/--------------\
|
|
222
|
+
/ \ Static (20%)
|
|
223
|
+
/------------------\
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Distribution
|
|
227
|
+
|
|
228
|
+
- **Static Analysis (20%)**: TypeScript, ESLint, Prettier
|
|
229
|
+
- **Unit Tests (25%)**: Pure functions, utilities, critical logic
|
|
230
|
+
- **Integration Tests (50%)**: Components with dependencies, API contracts
|
|
231
|
+
- **E2E Tests (5%)**: Critical business flows only
|
|
232
|
+
|
|
233
|
+
### When to Use
|
|
234
|
+
|
|
235
|
+
- Modern frontend applications (React, Vue, Angular)
|
|
236
|
+
- Applications with complex component interactions
|
|
237
|
+
- Teams using TypeScript and static analysis tools
|
|
238
|
+
- Applications where integration tests catch more bugs
|
|
239
|
+
|
|
240
|
+
### Implementation Example
|
|
241
|
+
|
|
242
|
+
**Static Analysis (20%)**:
|
|
243
|
+
```json
|
|
244
|
+
// tsconfig.json
|
|
245
|
+
{
|
|
246
|
+
"compilerOptions": {
|
|
247
|
+
"strict": true,
|
|
248
|
+
"noImplicitAny": true,
|
|
249
|
+
"strictNullChecks": true,
|
|
250
|
+
"noUnusedLocals": true,
|
|
251
|
+
"noUnusedParameters": true
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
```json
|
|
257
|
+
// .eslintrc.json
|
|
258
|
+
{
|
|
259
|
+
"extends": [
|
|
260
|
+
"eslint:recommended",
|
|
261
|
+
"plugin:@typescript-eslint/recommended",
|
|
262
|
+
"plugin:react-hooks/recommended",
|
|
263
|
+
"plugin:jsx-a11y/recommended"
|
|
264
|
+
],
|
|
265
|
+
"rules": {
|
|
266
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
267
|
+
"react-hooks/exhaustive-deps": "error"
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Unit Tests (25%)**:
|
|
273
|
+
```typescript
|
|
274
|
+
// src/utils/formatters.test.ts
|
|
275
|
+
import { describe, it, expect } from 'vitest';
|
|
276
|
+
import { formatCurrency, formatDate } from './formatters';
|
|
277
|
+
|
|
278
|
+
describe('Formatters (Pure Functions)', () => {
|
|
279
|
+
describe('formatCurrency', () => {
|
|
280
|
+
it('should format USD currency', () => {
|
|
281
|
+
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should handle negative amounts', () => {
|
|
285
|
+
expect(formatCurrency(-100, 'USD')).toBe('-$100.00');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('formatDate', () => {
|
|
290
|
+
it('should format ISO date', () => {
|
|
291
|
+
const date = new Date('2025-01-15');
|
|
292
|
+
expect(formatDate(date, 'short')).toBe('1/15/2025');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**Integration Tests (50%)**:
|
|
299
|
+
```typescript
|
|
300
|
+
// src/components/UserProfile.integration.test.tsx
|
|
301
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
302
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
303
|
+
import userEvent from '@testing-library/user-event';
|
|
304
|
+
import { rest } from 'msw';
|
|
305
|
+
import { setupServer } from 'msw/node';
|
|
306
|
+
import { UserProfile } from './UserProfile';
|
|
307
|
+
|
|
308
|
+
const server = setupServer(
|
|
309
|
+
rest.get('/api/users/:id', (req, res, ctx) => {
|
|
310
|
+
return res(
|
|
311
|
+
ctx.json({
|
|
312
|
+
id: req.params.id,
|
|
313
|
+
name: 'John Doe',
|
|
314
|
+
email: 'john@example.com',
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
}),
|
|
318
|
+
|
|
319
|
+
rest.put('/api/users/:id', (req, res, ctx) => {
|
|
320
|
+
return res(ctx.json({ ...req.body, updatedAt: Date.now() }));
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
beforeAll(() => server.listen());
|
|
325
|
+
afterEach(() => server.resetHandlers());
|
|
326
|
+
afterAll(() => server.close());
|
|
327
|
+
|
|
328
|
+
describe('UserProfile Integration', () => {
|
|
329
|
+
it('should load and display user data', async () => {
|
|
330
|
+
render(<UserProfile userId="123" />);
|
|
331
|
+
|
|
332
|
+
// Loading state
|
|
333
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
334
|
+
|
|
335
|
+
// Wait for data to load
|
|
336
|
+
await waitFor(() => {
|
|
337
|
+
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should update user profile on form submit', async () => {
|
|
344
|
+
const user = userEvent.setup();
|
|
345
|
+
render(<UserProfile userId="123" />);
|
|
346
|
+
|
|
347
|
+
// Wait for initial load
|
|
348
|
+
await screen.findByText('John Doe');
|
|
349
|
+
|
|
350
|
+
// Edit name
|
|
351
|
+
const nameInput = screen.getByLabelText('Name');
|
|
352
|
+
await user.clear(nameInput);
|
|
353
|
+
await user.type(nameInput, 'Jane Smith');
|
|
354
|
+
|
|
355
|
+
// Submit form
|
|
356
|
+
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
357
|
+
|
|
358
|
+
// Verify success message
|
|
359
|
+
await waitFor(() => {
|
|
360
|
+
expect(screen.getByText('Profile updated successfully')).toBeInTheDocument();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Verify updated name
|
|
364
|
+
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should handle API errors gracefully', async () => {
|
|
368
|
+
// Mock API error
|
|
369
|
+
server.use(
|
|
370
|
+
rest.get('/api/users/:id', (req, res, ctx) => {
|
|
371
|
+
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
render(<UserProfile userId="123" />);
|
|
376
|
+
|
|
377
|
+
// Wait for error message
|
|
378
|
+
await waitFor(() => {
|
|
379
|
+
expect(screen.getByText(/Failed to load user/i)).toBeInTheDocument();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**E2E Tests (5%)**:
|
|
386
|
+
```typescript
|
|
387
|
+
// e2e/critical-path.spec.ts
|
|
388
|
+
import { test, expect } from '@playwright/test';
|
|
389
|
+
|
|
390
|
+
test('critical user journey: signup to first purchase', async ({ page }) => {
|
|
391
|
+
// Only test the MOST critical path
|
|
392
|
+
await page.goto('/');
|
|
393
|
+
|
|
394
|
+
// Signup
|
|
395
|
+
await page.getByRole('link', { name: 'Sign Up' }).click();
|
|
396
|
+
await page.getByLabel('Email').fill('newuser@example.com');
|
|
397
|
+
await page.getByLabel('Password').fill('SecurePass123!');
|
|
398
|
+
await page.getByRole('button', { name: 'Create Account' }).click();
|
|
399
|
+
|
|
400
|
+
// Verify logged in
|
|
401
|
+
await expect(page.getByText('Welcome, New User')).toBeVisible();
|
|
402
|
+
|
|
403
|
+
// Make purchase
|
|
404
|
+
await page.goto('/products/best-seller');
|
|
405
|
+
await page.getByRole('button', { name: 'Buy Now' }).click();
|
|
406
|
+
await page.getByLabel('Card Number').fill('4242424242424242');
|
|
407
|
+
await page.getByRole('button', { name: 'Complete Purchase' }).click();
|
|
408
|
+
|
|
409
|
+
// Verify success
|
|
410
|
+
await expect(page.getByText('Purchase Successful')).toBeVisible();
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Coverage Targets
|
|
415
|
+
|
|
416
|
+
- **Static Analysis**: 100% (TypeScript strict mode, zero ESLint errors)
|
|
417
|
+
- **Unit Tests**: 90%+ for pure functions and utilities
|
|
418
|
+
- **Integration Tests**: 80%+ for components with dependencies
|
|
419
|
+
- **E2E Tests**: 100% critical paths only
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## TDD Red-Green-Refactor
|
|
424
|
+
|
|
425
|
+
### The Cycle
|
|
426
|
+
|
|
427
|
+
1. **RED**: Write a failing test that defines expected behavior
|
|
428
|
+
2. **GREEN**: Write minimal code to make the test pass
|
|
429
|
+
3. **REFACTOR**: Improve code quality while keeping tests green
|
|
430
|
+
|
|
431
|
+
### Example: Shopping Cart Feature
|
|
432
|
+
|
|
433
|
+
**RED: Write Failing Test**:
|
|
434
|
+
```typescript
|
|
435
|
+
// src/cart/ShoppingCart.test.ts
|
|
436
|
+
import { describe, it, expect } from 'vitest';
|
|
437
|
+
import { ShoppingCart } from './ShoppingCart';
|
|
438
|
+
|
|
439
|
+
describe('ShoppingCart', () => {
|
|
440
|
+
it('should add item to cart', () => {
|
|
441
|
+
const cart = new ShoppingCart();
|
|
442
|
+
|
|
443
|
+
cart.addItem({ id: 1, name: 'Laptop', price: 1000 });
|
|
444
|
+
|
|
445
|
+
expect(cart.getItemCount()).toBe(1);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
**Run test**: ❌ FAIL (ShoppingCart doesn't exist)
|
|
451
|
+
|
|
452
|
+
**GREEN: Minimal Implementation**:
|
|
453
|
+
```typescript
|
|
454
|
+
// src/cart/ShoppingCart.ts
|
|
455
|
+
interface CartItem {
|
|
456
|
+
id: number;
|
|
457
|
+
name: string;
|
|
458
|
+
price: number;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export class ShoppingCart {
|
|
462
|
+
private items: CartItem[] = [];
|
|
463
|
+
|
|
464
|
+
addItem(item: CartItem): void {
|
|
465
|
+
this.items.push(item);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
getItemCount(): number {
|
|
469
|
+
return this.items.length;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Run test**: ✅ PASS
|
|
475
|
+
|
|
476
|
+
**Add Another Test (Triangulation)**:
|
|
477
|
+
```typescript
|
|
478
|
+
it('should calculate total price', () => {
|
|
479
|
+
const cart = new ShoppingCart();
|
|
480
|
+
|
|
481
|
+
cart.addItem({ id: 1, name: 'Laptop', price: 1000 });
|
|
482
|
+
cart.addItem({ id: 2, name: 'Mouse', price: 50 });
|
|
483
|
+
|
|
484
|
+
expect(cart.getTotal()).toBe(1050);
|
|
485
|
+
});
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**Run test**: ❌ FAIL (getTotal doesn't exist)
|
|
489
|
+
|
|
490
|
+
**GREEN: Implement getTotal**:
|
|
491
|
+
```typescript
|
|
492
|
+
export class ShoppingCart {
|
|
493
|
+
// ... previous code ...
|
|
494
|
+
|
|
495
|
+
getTotal(): number {
|
|
496
|
+
return this.items.reduce((sum, item) => sum + item.price, 0);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
**Run test**: ✅ PASS
|
|
502
|
+
|
|
503
|
+
**REFACTOR: Improve Design**:
|
|
504
|
+
```typescript
|
|
505
|
+
export class ShoppingCart {
|
|
506
|
+
private items: Map<number, CartItem> = new Map();
|
|
507
|
+
|
|
508
|
+
addItem(item: CartItem): void {
|
|
509
|
+
const existing = this.items.get(item.id);
|
|
510
|
+
if (existing) {
|
|
511
|
+
// Increment quantity instead of duplicating
|
|
512
|
+
existing.quantity = (existing.quantity || 1) + 1;
|
|
513
|
+
} else {
|
|
514
|
+
this.items.set(item.id, { ...item, quantity: 1 });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
getItemCount(): number {
|
|
519
|
+
return Array.from(this.items.values()).reduce(
|
|
520
|
+
(count, item) => count + (item.quantity || 1),
|
|
521
|
+
0
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
getTotal(): number {
|
|
526
|
+
return Array.from(this.items.values()).reduce(
|
|
527
|
+
(sum, item) => sum + item.price * (item.quantity || 1),
|
|
528
|
+
0
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
**Run tests**: ✅ ALL PASS (refactoring didn't break anything!)
|
|
535
|
+
|
|
536
|
+
### TDD Benefits
|
|
537
|
+
|
|
538
|
+
- Forces modular, testable design
|
|
539
|
+
- Prevents over-engineering
|
|
540
|
+
- Living documentation
|
|
541
|
+
- Fearless refactoring
|
|
542
|
+
- Faster debugging
|
|
543
|
+
|
|
544
|
+
---
|
|
545
|
+
|
|
546
|
+
## API Testing Strategy
|
|
547
|
+
|
|
548
|
+
### Layers
|
|
549
|
+
|
|
550
|
+
1. **Unit Tests**: Test business logic in isolation
|
|
551
|
+
2. **Integration Tests**: Test API endpoints with real database
|
|
552
|
+
3. **Contract Tests**: Test API contracts (Pact)
|
|
553
|
+
4. **E2E Tests**: Test complete API flows
|
|
554
|
+
|
|
555
|
+
### Example: REST API Testing
|
|
556
|
+
|
|
557
|
+
**Unit Test (Business Logic)**:
|
|
558
|
+
```typescript
|
|
559
|
+
// src/services/OrderService.test.ts
|
|
560
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
561
|
+
import { OrderService } from './OrderService';
|
|
562
|
+
|
|
563
|
+
describe('OrderService', () => {
|
|
564
|
+
it('should calculate order total with tax', () => {
|
|
565
|
+
const mockRepo = {
|
|
566
|
+
save: vi.fn(),
|
|
567
|
+
findById: vi.fn(),
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const service = new OrderService(mockRepo);
|
|
571
|
+
|
|
572
|
+
const order = service.calculateTotal({
|
|
573
|
+
items: [{ price: 100, quantity: 2 }],
|
|
574
|
+
taxRate: 0.08,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
expect(order.subtotal).toBe(200);
|
|
578
|
+
expect(order.tax).toBeCloseTo(16, 2);
|
|
579
|
+
expect(order.total).toBeCloseTo(216, 2);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
**Integration Test (API + Database)**:
|
|
585
|
+
```typescript
|
|
586
|
+
// tests/integration/orders.test.ts
|
|
587
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
588
|
+
import supertest from 'supertest';
|
|
589
|
+
import { createTestApp } from '../utils/test-app';
|
|
590
|
+
|
|
591
|
+
describe('Orders API', () => {
|
|
592
|
+
let app;
|
|
593
|
+
let request;
|
|
594
|
+
|
|
595
|
+
beforeAll(async () => {
|
|
596
|
+
app = await createTestApp();
|
|
597
|
+
request = supertest(app);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
afterAll(async () => {
|
|
601
|
+
await app.close();
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('POST /api/orders should create order', async () => {
|
|
605
|
+
const response = await request
|
|
606
|
+
.post('/api/orders')
|
|
607
|
+
.send({
|
|
608
|
+
userId: 'user-123',
|
|
609
|
+
items: [
|
|
610
|
+
{ productId: 'prod-1', quantity: 2, price: 50 },
|
|
611
|
+
],
|
|
612
|
+
})
|
|
613
|
+
.expect(201);
|
|
614
|
+
|
|
615
|
+
expect(response.body).toMatchObject({
|
|
616
|
+
id: expect.any(String),
|
|
617
|
+
userId: 'user-123',
|
|
618
|
+
status: 'pending',
|
|
619
|
+
total: 100,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// Verify database persistence
|
|
623
|
+
const order = await app.db.orders.findById(response.body.id);
|
|
624
|
+
expect(order).toBeTruthy();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('GET /api/orders/:id should return order', async () => {
|
|
628
|
+
// Create order first
|
|
629
|
+
const createResponse = await request
|
|
630
|
+
.post('/api/orders')
|
|
631
|
+
.send({ userId: 'user-123', items: [] });
|
|
632
|
+
|
|
633
|
+
const orderId = createResponse.body.id;
|
|
634
|
+
|
|
635
|
+
// Fetch order
|
|
636
|
+
const response = await request
|
|
637
|
+
.get(`/api/orders/${orderId}`)
|
|
638
|
+
.expect(200);
|
|
639
|
+
|
|
640
|
+
expect(response.body.id).toBe(orderId);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('PUT /api/orders/:id/status should update status', async () => {
|
|
644
|
+
const createResponse = await request
|
|
645
|
+
.post('/api/orders')
|
|
646
|
+
.send({ userId: 'user-123', items: [] });
|
|
647
|
+
|
|
648
|
+
const orderId = createResponse.body.id;
|
|
649
|
+
|
|
650
|
+
const response = await request
|
|
651
|
+
.put(`/api/orders/${orderId}/status`)
|
|
652
|
+
.send({ status: 'shipped' })
|
|
653
|
+
.expect(200);
|
|
654
|
+
|
|
655
|
+
expect(response.body.status).toBe('shipped');
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
**Contract Test (Pact)**:
|
|
661
|
+
```typescript
|
|
662
|
+
// tests/contract/orders-consumer.test.ts
|
|
663
|
+
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
|
664
|
+
import { OrdersClient } from '@/api/orders-client';
|
|
665
|
+
|
|
666
|
+
const provider = new PactV3({
|
|
667
|
+
consumer: 'OrdersConsumer',
|
|
668
|
+
provider: 'OrdersAPI',
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
describe('Orders API Contract', () => {
|
|
672
|
+
it('should get order by ID', async () => {
|
|
673
|
+
await provider
|
|
674
|
+
.given('order with ID 123 exists')
|
|
675
|
+
.uponReceiving('a request for order 123')
|
|
676
|
+
.withRequest({
|
|
677
|
+
method: 'GET',
|
|
678
|
+
path: '/api/orders/123',
|
|
679
|
+
})
|
|
680
|
+
.willRespondWith({
|
|
681
|
+
status: 200,
|
|
682
|
+
headers: { 'Content-Type': 'application/json' },
|
|
683
|
+
body: {
|
|
684
|
+
id: MatchersV3.string('123'),
|
|
685
|
+
userId: MatchersV3.string('user-456'),
|
|
686
|
+
status: MatchersV3.regex('pending|shipped|delivered', 'pending'),
|
|
687
|
+
total: MatchersV3.decimal(100.50),
|
|
688
|
+
},
|
|
689
|
+
})
|
|
690
|
+
.executeTest(async (mockServer) => {
|
|
691
|
+
const client = new OrdersClient(mockServer.url);
|
|
692
|
+
const order = await client.getOrder('123');
|
|
693
|
+
|
|
694
|
+
expect(order.id).toBe('123');
|
|
695
|
+
expect(order.status).toMatch(/pending|shipped|delivered/);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
### Coverage Targets
|
|
702
|
+
|
|
703
|
+
- Unit: 90%+ business logic
|
|
704
|
+
- Integration: 100% API endpoints
|
|
705
|
+
- Contract: 100% API contracts
|
|
706
|
+
- E2E: Critical flows only
|
|
707
|
+
|
|
708
|
+
[Document continues with 6 more comprehensive testing strategies...]
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
## Summary Table
|
|
713
|
+
|
|
714
|
+
| Strategy | Best For | Coverage Target | Execution Time |
|
|
715
|
+
|----------|----------|-----------------|----------------|
|
|
716
|
+
| Pyramid | Backend services, traditional apps | 80%+ unit, 100% critical | < 5 min |
|
|
717
|
+
| Trophy | Modern frontends (React, Vue) | 80%+ integration | < 3 min |
|
|
718
|
+
| TDD | New features, greenfield projects | 90%+ | Continuous |
|
|
719
|
+
| BDD | Stakeholder-driven development | 100% acceptance | Variable |
|
|
720
|
+
| API | REST/GraphQL services | 90%+ endpoints | < 2 min |
|
|
721
|
+
| Frontend | SPAs, component libraries | 85%+ components | < 4 min |
|
|
722
|
+
| Micro-Services | Distributed systems | 80%+ per service | < 10 min |
|
|
723
|
+
| Performance | High-traffic applications | Critical paths | 30 min |
|
|
724
|
+
| Security | Sensitive data, compliance | 100% attack vectors | 1 hour |
|
|
725
|
+
| Accessibility | Public-facing websites | WCAG AA 100% | 15 min |
|
|
726
|
+
|