@mars-stack/cli 0.2.0 → 1.0.2
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/dist/index.js +137 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +15 -0
- package/template/src/styles/globals.css +7 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Skill: Add CRUD Routes
|
|
2
|
+
|
|
3
|
+
Generate a complete set of CRUD API routes for a resource, following MARS conventions for authentication, validation, ownership, and error handling.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to create endpoints for listing, creating, reading, updating, or deleting a resource.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- A Prisma model exists for the resource (see `add-prisma-model` skill).
|
|
12
|
+
- Server access functions exist in `src/features/<name>/server/`.
|
|
13
|
+
- Zod validation schemas exist in `src/features/<name>/validation/schemas.ts`.
|
|
14
|
+
|
|
15
|
+
## Generated Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/app/api/protected/<resource>/
|
|
19
|
+
├── route.ts # GET (list) + POST (create)
|
|
20
|
+
└── [id]/
|
|
21
|
+
└── route.ts # GET (one) + PATCH (update) + DELETE
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Step 1: Collection Route (`route.ts`)
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
28
|
+
import { findAllForUser, createForUser } from '@/features/<resource>/server';
|
|
29
|
+
import { resourceSchemas } from '@/features/<resource>/validation/schemas';
|
|
30
|
+
import { NextResponse } from 'next/server';
|
|
31
|
+
|
|
32
|
+
export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
33
|
+
try {
|
|
34
|
+
const items = await findAllForUser(request.session.userId);
|
|
35
|
+
return NextResponse.json(items);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return handleApiError(error, { endpoint: '/api/protected/<resource>' });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
42
|
+
try {
|
|
43
|
+
const body = resourceSchemas.create.parse(await request.json());
|
|
44
|
+
const item = await createForUser(request.session.userId, body);
|
|
45
|
+
return NextResponse.json(item, { status: 201 });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return handleApiError(error, { endpoint: '/api/protected/<resource>' });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Step 2: Item Route (`[id]/route.ts`)
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { handleApiError, withOwnership, withAuth, type AuthenticatedRequest } from '@/lib/mars';
|
|
56
|
+
import { findById, updateById, deleteById } from '@/features/<resource>/server';
|
|
57
|
+
import { resourceSchemas } from '@/features/<resource>/validation/schemas';
|
|
58
|
+
import { NextResponse } from 'next/server';
|
|
59
|
+
|
|
60
|
+
async function getResourceOwner(
|
|
61
|
+
_request: AuthenticatedRequest,
|
|
62
|
+
context: { params: Promise<{ [key: string]: string }> },
|
|
63
|
+
): Promise<string | null> {
|
|
64
|
+
const { id } = await context.params;
|
|
65
|
+
const item = await findById(id);
|
|
66
|
+
return item?.userId ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const GET = withOwnership(getResourceOwner, async (request, context) => {
|
|
70
|
+
try {
|
|
71
|
+
const { id } = await context.params;
|
|
72
|
+
const item = await findById(id);
|
|
73
|
+
return NextResponse.json(item);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return handleApiError(error, { endpoint: '/api/protected/<resource>/[id]' });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const PATCH = withOwnership(getResourceOwner, async (request, context) => {
|
|
80
|
+
try {
|
|
81
|
+
const { id } = await context.params;
|
|
82
|
+
const body = resourceSchemas.update.parse(await request.json());
|
|
83
|
+
const item = await updateById(id, body);
|
|
84
|
+
return NextResponse.json(item);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return handleApiError(error, { endpoint: '/api/protected/<resource>/[id]' });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const DELETE = withOwnership(getResourceOwner, async (_request, context) => {
|
|
91
|
+
try {
|
|
92
|
+
const { id } = await context.params;
|
|
93
|
+
await deleteById(id);
|
|
94
|
+
return NextResponse.json({ message: 'Deleted' });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return handleApiError(error, { endpoint: '/api/protected/<resource>/[id]' });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Step 3: Server Access Functions
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import 'server-only';
|
|
105
|
+
|
|
106
|
+
import { prisma } from '@/lib/prisma';
|
|
107
|
+
import type { CreateInput, UpdateInput } from '../validation/schemas';
|
|
108
|
+
|
|
109
|
+
export async function findAllForUser(userId: string) {
|
|
110
|
+
return prisma.<resource>.findMany({
|
|
111
|
+
where: { userId },
|
|
112
|
+
orderBy: { createdAt: 'desc' },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function findById(id: string) {
|
|
117
|
+
return prisma.<resource>.findUnique({ where: { id } });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function createForUser(userId: string, data: CreateInput) {
|
|
121
|
+
return prisma.<resource>.create({
|
|
122
|
+
data: { ...data, userId },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function updateById(id: string, data: UpdateInput) {
|
|
127
|
+
return prisma.<resource>.update({
|
|
128
|
+
where: { id },
|
|
129
|
+
data,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function deleteById(id: string) {
|
|
134
|
+
return prisma.<resource>.delete({ where: { id } });
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Step 4: Validation Schemas
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
import { z } from 'zod';
|
|
142
|
+
|
|
143
|
+
export const resourceSchemas = {
|
|
144
|
+
create: z.object({
|
|
145
|
+
name: z.string().min(1, 'Name is required').max(100),
|
|
146
|
+
description: z.string().max(500).optional(),
|
|
147
|
+
}),
|
|
148
|
+
update: z.object({
|
|
149
|
+
name: z.string().min(1).max(100).optional(),
|
|
150
|
+
description: z.string().max(500).optional(),
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type CreateInput = z.infer<typeof resourceSchemas.create>;
|
|
155
|
+
export type UpdateInput = z.infer<typeof resourceSchemas.update>;
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Pagination (Optional)
|
|
159
|
+
|
|
160
|
+
For resources that may grow large, add pagination to the list endpoint:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
164
|
+
try {
|
|
165
|
+
const url = new URL(request.url);
|
|
166
|
+
const page = Math.max(1, Number(url.searchParams.get('page') || '1'));
|
|
167
|
+
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') || '20')));
|
|
168
|
+
const skip = (page - 1) * limit;
|
|
169
|
+
|
|
170
|
+
const [items, total] = await Promise.all([
|
|
171
|
+
prisma.<resource>.findMany({
|
|
172
|
+
where: { userId: request.session.userId },
|
|
173
|
+
orderBy: { createdAt: 'desc' },
|
|
174
|
+
skip,
|
|
175
|
+
take: limit,
|
|
176
|
+
}),
|
|
177
|
+
prisma.<resource>.count({
|
|
178
|
+
where: { userId: request.session.userId },
|
|
179
|
+
}),
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
return NextResponse.json({
|
|
183
|
+
items,
|
|
184
|
+
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return handleApiError(error, { endpoint: '/api/protected/<resource>' });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Admin Variant
|
|
193
|
+
|
|
194
|
+
For admin-only CRUD (e.g., managing all users' resources):
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
export const GET = withRole(['admin'], async (request, context) => {
|
|
198
|
+
// No userId scoping -- admin sees all
|
|
199
|
+
const items = await prisma.<resource>.findMany({
|
|
200
|
+
orderBy: { createdAt: 'desc' },
|
|
201
|
+
include: { user: { select: { id: true, name: true, email: true } } },
|
|
202
|
+
});
|
|
203
|
+
return NextResponse.json(items);
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Middleware Registration
|
|
208
|
+
|
|
209
|
+
Add the new protected routes to `src/middleware.ts` CSRF protection if not already covered by the `/api/protected` prefix match.
|
|
210
|
+
|
|
211
|
+
## Checklist
|
|
212
|
+
|
|
213
|
+
- [ ] Collection route (GET list + POST create)
|
|
214
|
+
- [ ] Item route (GET one + PATCH update + DELETE)
|
|
215
|
+
- [ ] Ownership verification via `withOwnership`
|
|
216
|
+
- [ ] All queries scoped by `userId`
|
|
217
|
+
- [ ] Zod validation on create and update
|
|
218
|
+
- [ ] `handleApiError` in every catch block
|
|
219
|
+
- [ ] Server access functions with `'server-only'`
|
|
220
|
+
- [ ] Pagination for potentially large collections
|
|
221
|
+
- [ ] Tests for each endpoint
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Skill: Add an E2E Test
|
|
2
|
+
|
|
3
|
+
Write a Playwright end-to-end test for a MARS feature or user flow.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add E2E tests, integration tests, browser tests, or test a full user flow.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
MARS uses Playwright for E2E tests. Tests live in `e2e/` at the project root and run against the full application.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
Config lives at `playwright.config.ts`. Key settings:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { defineConfig } from '@playwright/test';
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
testDir: './e2e',
|
|
22
|
+
fullyParallel: true,
|
|
23
|
+
retries: process.env.CI ? 2 : 0,
|
|
24
|
+
reporter: process.env.CI ? 'github' : 'html',
|
|
25
|
+
use: {
|
|
26
|
+
baseURL: 'http://localhost:3000',
|
|
27
|
+
trace: 'on-first-retry',
|
|
28
|
+
},
|
|
29
|
+
webServer: {
|
|
30
|
+
command: 'yarn dev',
|
|
31
|
+
port: 3000,
|
|
32
|
+
reuseExistingServer: !process.env.CI,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Template: Auth Flow Test
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// e2e/auth.spec.ts
|
|
41
|
+
import { test, expect } from '@playwright/test';
|
|
42
|
+
|
|
43
|
+
test.describe('Authentication', () => {
|
|
44
|
+
test('user can sign in', async ({ page }) => {
|
|
45
|
+
await page.goto('/sign-in');
|
|
46
|
+
|
|
47
|
+
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
|
48
|
+
|
|
49
|
+
await page.getByLabel('Email').fill('test@example.com');
|
|
50
|
+
await page.getByLabel('Password').fill('password123');
|
|
51
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
52
|
+
|
|
53
|
+
await expect(page).toHaveURL('/dashboard');
|
|
54
|
+
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('shows error for invalid credentials', async ({ page }) => {
|
|
58
|
+
await page.goto('/sign-in');
|
|
59
|
+
|
|
60
|
+
await page.getByLabel('Email').fill('wrong@example.com');
|
|
61
|
+
await page.getByLabel('Password').fill('wrongpassword');
|
|
62
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
63
|
+
|
|
64
|
+
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
|
|
65
|
+
await expect(page).toHaveURL('/sign-in');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('redirects to sign-in when accessing protected route', async ({ page }) => {
|
|
69
|
+
await page.goto('/dashboard');
|
|
70
|
+
await expect(page).toHaveURL(/sign-in/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Template: CRUD Flow Test
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// e2e/projects.spec.ts
|
|
79
|
+
import { test, expect } from '@playwright/test';
|
|
80
|
+
|
|
81
|
+
test.describe('Projects', () => {
|
|
82
|
+
test.beforeEach(async ({ page }) => {
|
|
83
|
+
// Sign in before each test
|
|
84
|
+
await page.goto('/sign-in');
|
|
85
|
+
await page.getByLabel('Email').fill('test@example.com');
|
|
86
|
+
await page.getByLabel('Password').fill('password123');
|
|
87
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
88
|
+
await expect(page).toHaveURL('/dashboard');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('can create a new project', async ({ page }) => {
|
|
92
|
+
await page.goto('/projects');
|
|
93
|
+
await page.getByRole('button', { name: /create/i }).click();
|
|
94
|
+
|
|
95
|
+
await page.getByLabel('Name').fill('My Test Project');
|
|
96
|
+
await page.getByLabel('Description').fill('A test project for E2E');
|
|
97
|
+
await page.getByRole('button', { name: /save/i }).click();
|
|
98
|
+
|
|
99
|
+
await expect(page.getByText('My Test Project')).toBeVisible();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('can edit a project', async ({ page }) => {
|
|
103
|
+
await page.goto('/projects');
|
|
104
|
+
await page.getByText('My Test Project').click();
|
|
105
|
+
await page.getByRole('button', { name: /edit/i }).click();
|
|
106
|
+
|
|
107
|
+
await page.getByLabel('Name').clear();
|
|
108
|
+
await page.getByLabel('Name').fill('Updated Project');
|
|
109
|
+
await page.getByRole('button', { name: /save/i }).click();
|
|
110
|
+
|
|
111
|
+
await expect(page.getByText('Updated Project')).toBeVisible();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('can delete a project', async ({ page }) => {
|
|
115
|
+
await page.goto('/projects');
|
|
116
|
+
await page.getByText('Updated Project').click();
|
|
117
|
+
await page.getByRole('button', { name: /delete/i }).click();
|
|
118
|
+
await page.getByRole('button', { name: /confirm/i }).click();
|
|
119
|
+
|
|
120
|
+
await expect(page.getByText('Updated Project')).not.toBeVisible();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Auth Helper (Reusable Login)
|
|
126
|
+
|
|
127
|
+
For tests that need authentication, create a helper:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// e2e/helpers/auth.ts
|
|
131
|
+
import type { Page } from '@playwright/test';
|
|
132
|
+
|
|
133
|
+
export async function signIn(page: Page, email = 'test@example.com', password = 'password123') {
|
|
134
|
+
await page.goto('/sign-in');
|
|
135
|
+
await page.getByLabel('Email').fill(email);
|
|
136
|
+
await page.getByLabel('Password').fill(password);
|
|
137
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Or use Playwright's `storageState` for persistent auth:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// e2e/auth.setup.ts
|
|
145
|
+
import { test as setup, expect } from '@playwright/test';
|
|
146
|
+
|
|
147
|
+
const authFile = 'e2e/.auth/user.json';
|
|
148
|
+
|
|
149
|
+
setup('authenticate', async ({ page }) => {
|
|
150
|
+
await page.goto('/sign-in');
|
|
151
|
+
await page.getByLabel('Email').fill('test@example.com');
|
|
152
|
+
await page.getByLabel('Password').fill('password123');
|
|
153
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
154
|
+
await expect(page).toHaveURL('/dashboard');
|
|
155
|
+
await page.context().storageState({ path: authFile });
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Then in `playwright.config.ts`:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
projects: [
|
|
163
|
+
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
|
164
|
+
{
|
|
165
|
+
name: 'chromium',
|
|
166
|
+
use: { ...devices['Desktop Chrome'], storageState: authFile },
|
|
167
|
+
dependencies: ['setup'],
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Patterns
|
|
173
|
+
|
|
174
|
+
### Testing Form Validation
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
test('shows validation errors for empty form', async ({ page }) => {
|
|
178
|
+
await page.goto('/contact');
|
|
179
|
+
await page.getByRole('button', { name: /send/i }).click();
|
|
180
|
+
|
|
181
|
+
await expect(page.getByText(/name is required/i)).toBeVisible();
|
|
182
|
+
await expect(page.getByText(/invalid email/i)).toBeVisible();
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Testing API Responses via Network
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
test('handles server errors gracefully', async ({ page }) => {
|
|
190
|
+
await page.route('**/api/protected/projects', (route) =>
|
|
191
|
+
route.fulfill({ status: 503, body: JSON.stringify({ error: 'Service unavailable' }) }),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
await page.goto('/projects');
|
|
195
|
+
await expect(page.getByText(/try again/i)).toBeVisible();
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Visual Regression
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
test('dashboard looks correct', async ({ page }) => {
|
|
203
|
+
await page.goto('/dashboard');
|
|
204
|
+
await expect(page).toHaveScreenshot('dashboard.png', {
|
|
205
|
+
maxDiffPixelRatio: 0.01,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Running Tests
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
yarn test:e2e # Run all E2E tests
|
|
214
|
+
yarn test:e2e:ui # Open Playwright UI
|
|
215
|
+
npx playwright test --headed # Run with visible browser
|
|
216
|
+
npx playwright show-report # View HTML report
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Checklist
|
|
220
|
+
|
|
221
|
+
- [ ] Test file in `e2e/` directory
|
|
222
|
+
- [ ] Tests use `test.describe` for grouping
|
|
223
|
+
- [ ] Auth helper or `storageState` for authenticated tests
|
|
224
|
+
- [ ] Uses role-based selectors (`getByRole`, `getByLabel`, `getByText`) over CSS selectors
|
|
225
|
+
- [ ] Tests both happy path and error paths
|
|
226
|
+
- [ ] No hard-coded waits (`waitForTimeout`) -- use `expect` assertions
|
|
227
|
+
- [ ] Test data cleaned up (or use test-specific seed data)
|