@opensaas/stack-ui 0.1.6 → 0.3.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/.turbo/turbo-build.log +3 -2
- package/CHANGELOG.md +11 -0
- package/dist/components/AdminUI.d.ts +2 -1
- package/dist/components/AdminUI.d.ts.map +1 -1
- package/dist/components/AdminUI.js +2 -2
- package/dist/components/ItemFormClient.d.ts.map +1 -1
- package/dist/components/ItemFormClient.js +78 -60
- package/dist/components/Navigation.d.ts +2 -1
- package/dist/components/Navigation.d.ts.map +1 -1
- package/dist/components/Navigation.js +3 -2
- package/dist/components/UserMenu.d.ts +11 -0
- package/dist/components/UserMenu.d.ts.map +1 -0
- package/dist/components/UserMenu.js +18 -0
- package/dist/components/fields/TextField.d.ts +2 -1
- package/dist/components/fields/TextField.d.ts.map +1 -1
- package/dist/components/fields/TextField.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/primitives/button.d.ts +1 -1
- package/dist/styles/globals.css +25 -1
- package/package.json +14 -5
- package/src/components/AdminUI.tsx +3 -0
- package/src/components/ItemFormClient.tsx +84 -62
- package/src/components/Navigation.tsx +9 -20
- package/src/components/UserMenu.tsx +44 -0
- package/src/components/fields/TextField.tsx +7 -2
- package/src/index.ts +2 -0
- package/tests/browser/README.md +154 -0
- package/tests/browser/fields/CheckboxField.browser.test.tsx +245 -0
- package/tests/browser/fields/SelectField.browser.test.tsx +263 -0
- package/tests/browser/fields/TextField.browser.test.tsx +204 -0
- package/tests/browser/fields/__screenshots__/CheckboxField.browser.test.tsx/CheckboxField--Browser--edit-mode-should-not-be-clickable-when-disabled-1.png +0 -0
- package/tests/browser/fields/__screenshots__/CheckboxField.browser.test.tsx/CheckboxField--Browser--edit-mode-should-toggle-state-with-multiple-clicks-1.png +0 -0
- package/tests/browser/fields/__screenshots__/TextField.browser.test.tsx/TextField--Browser--edit-mode-should-handle-special-characters-1.png +0 -0
- package/tests/browser/fields/__screenshots__/TextField.browser.test.tsx/TextField--Browser--edit-mode-should-support-copy-and-paste-1.png +0 -0
- package/tests/browser/primitives/Button.browser.test.tsx +122 -0
- package/tests/browser/primitives/Dialog.browser.test.tsx +279 -0
- package/tests/browser/primitives/__screenshots__/Button.browser.test.tsx/Button--Browser--should-not-trigger-click-when-disabled-1.png +0 -0
- package/tests/components/CheckboxField.test.tsx +130 -0
- package/tests/components/DeleteButton.test.tsx +331 -0
- package/tests/components/IntegerField.test.tsx +147 -0
- package/tests/components/ListTable.test.tsx +457 -0
- package/tests/components/ListViewClient.test.tsx +415 -0
- package/tests/components/SearchBar.test.tsx +254 -0
- package/tests/components/SelectField.test.tsx +192 -0
- package/vitest.config.ts +20 -0
|
@@ -62,68 +62,81 @@ export function ItemFormClient({
|
|
|
62
62
|
setGeneralError(null)
|
|
63
63
|
|
|
64
64
|
startTransition(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const fieldConfig = fields[fieldName]
|
|
72
|
-
|
|
73
|
-
// Skip password fields that have { isSet: boolean } value (not being changed)
|
|
74
|
-
if (typeof value === 'object' && value !== null && 'isSet' in value) {
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
65
|
+
// Transform relationship fields to Prisma format
|
|
66
|
+
// Filter out password fields with isSet objects (unchanged passwords)
|
|
67
|
+
// File/Image fields: pass File objects through (Next.js will serialize them)
|
|
68
|
+
const transformedData: Record<string, unknown> = {}
|
|
69
|
+
for (const [fieldName, value] of Object.entries(formData)) {
|
|
70
|
+
const fieldConfig = fields[fieldName]
|
|
77
71
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
transformedData[fieldName] = {
|
|
92
|
-
connect: { id: value },
|
|
93
|
-
}
|
|
72
|
+
// Skip password fields that have { isSet: boolean } value (not being changed)
|
|
73
|
+
if (typeof value === 'object' && value !== null && 'isSet' in value) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Transform relationship fields - check discriminated union type
|
|
78
|
+
const fieldAny = fieldConfig as { type: string; many?: boolean }
|
|
79
|
+
if (fieldAny?.type === 'relationship') {
|
|
80
|
+
if (fieldAny.many) {
|
|
81
|
+
// Many relationship: use connect format
|
|
82
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
83
|
+
transformedData[fieldName] = {
|
|
84
|
+
connect: value.map((id: string) => ({ id })),
|
|
94
85
|
}
|
|
95
86
|
}
|
|
96
87
|
} else {
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
// Single relationship: use connect format
|
|
89
|
+
if (value) {
|
|
90
|
+
transformedData[fieldName] = {
|
|
91
|
+
connect: { id: value },
|
|
92
|
+
}
|
|
93
|
+
}
|
|
100
94
|
}
|
|
95
|
+
} else {
|
|
96
|
+
// Non-relationship field: pass through (including File objects for file/image fields)
|
|
97
|
+
// File objects will be serialized by Next.js server action
|
|
98
|
+
transformedData[fieldName] = value
|
|
101
99
|
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result =
|
|
103
|
+
mode === 'create'
|
|
104
|
+
? await serverAction({
|
|
105
|
+
listKey,
|
|
106
|
+
action: 'create',
|
|
107
|
+
data: transformedData,
|
|
108
|
+
})
|
|
109
|
+
: await serverAction({
|
|
110
|
+
listKey,
|
|
111
|
+
action: 'update',
|
|
112
|
+
id: itemId!,
|
|
113
|
+
data: transformedData,
|
|
114
|
+
})
|
|
102
115
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
: await serverAction({
|
|
111
|
-
listKey,
|
|
112
|
-
action: 'update',
|
|
113
|
-
id: itemId!,
|
|
114
|
-
data: transformedData,
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
if (result) {
|
|
116
|
+
// Check if result has the new format with success/error fields
|
|
117
|
+
if (result && typeof result === 'object' && 'success' in result) {
|
|
118
|
+
const actionResult = result as
|
|
119
|
+
| { success: true; data: unknown }
|
|
120
|
+
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
121
|
+
|
|
122
|
+
if (actionResult.success) {
|
|
118
123
|
// Navigate back to list view
|
|
119
124
|
router.push(`${basePath}/${urlKey}`)
|
|
120
125
|
router.refresh()
|
|
121
126
|
} else {
|
|
122
|
-
|
|
127
|
+
// Handle error response
|
|
128
|
+
if (actionResult.fieldErrors) {
|
|
129
|
+
setErrors(actionResult.fieldErrors)
|
|
130
|
+
}
|
|
131
|
+
setGeneralError(actionResult.error)
|
|
123
132
|
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
} else if (result) {
|
|
134
|
+
// Legacy format: result is the data itself
|
|
135
|
+
router.push(`${basePath}/${urlKey}`)
|
|
136
|
+
router.refresh()
|
|
137
|
+
} else {
|
|
138
|
+
// null result means access denied
|
|
139
|
+
setGeneralError('Access denied or operation failed')
|
|
127
140
|
}
|
|
128
141
|
})
|
|
129
142
|
}
|
|
@@ -135,22 +148,31 @@ export function ItemFormClient({
|
|
|
135
148
|
setShowDeleteConfirm(false)
|
|
136
149
|
|
|
137
150
|
startTransition(async () => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
const result = await serverAction({
|
|
152
|
+
listKey,
|
|
153
|
+
action: 'delete',
|
|
154
|
+
id: itemId,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// Check if result has the new format with success/error fields
|
|
158
|
+
if (result && typeof result === 'object' && 'success' in result) {
|
|
159
|
+
const actionResult = result as
|
|
160
|
+
| { success: true; data: unknown }
|
|
161
|
+
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
|
162
|
+
|
|
163
|
+
if (actionResult.success) {
|
|
146
164
|
router.push(`${basePath}/${urlKey}`)
|
|
147
165
|
router.refresh()
|
|
148
166
|
} else {
|
|
149
|
-
setGeneralError(
|
|
167
|
+
setGeneralError(actionResult.error)
|
|
150
168
|
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
169
|
+
} else if (result) {
|
|
170
|
+
// Legacy format: result is the data itself
|
|
171
|
+
router.push(`${basePath}/${urlKey}`)
|
|
172
|
+
router.refresh()
|
|
173
|
+
} else {
|
|
174
|
+
// null result means access denied
|
|
175
|
+
setGeneralError('Access denied or failed to delete item')
|
|
154
176
|
}
|
|
155
177
|
})
|
|
156
178
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import Link from 'next/link.js'
|
|
2
2
|
import { formatListName } from '../lib/utils.js'
|
|
3
3
|
import { AccessContext, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
4
|
+
import { UserMenu } from './UserMenu.js'
|
|
4
5
|
|
|
5
6
|
export interface NavigationProps<TPrisma> {
|
|
6
7
|
context: AccessContext<TPrisma>
|
|
7
8
|
config: OpenSaasConfig
|
|
8
9
|
basePath?: string
|
|
9
10
|
currentPath?: string
|
|
11
|
+
onSignOut?: () => Promise<void>
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -18,6 +20,7 @@ export function Navigation<TPrisma>({
|
|
|
18
20
|
config,
|
|
19
21
|
basePath = '/admin',
|
|
20
22
|
currentPath = '',
|
|
23
|
+
onSignOut,
|
|
21
24
|
}: NavigationProps<TPrisma>) {
|
|
22
25
|
const lists = Object.keys(config.lists || {})
|
|
23
26
|
|
|
@@ -90,27 +93,13 @@ export function Navigation<TPrisma>({
|
|
|
90
93
|
</div>
|
|
91
94
|
</div>
|
|
92
95
|
|
|
93
|
-
{/* Footer */}
|
|
96
|
+
{/* Footer - User Menu */}
|
|
94
97
|
{context.session && (
|
|
95
|
-
<
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
(context.session.data as Record<string, unknown>)?.name,
|
|
101
|
-
)?.[0]?.toUpperCase() || '?'}
|
|
102
|
-
</span>
|
|
103
|
-
</div>
|
|
104
|
-
<div className="flex-1 min-w-0">
|
|
105
|
-
<p className="text-sm font-medium truncate">
|
|
106
|
-
{String((context.session.data as Record<string, unknown>)?.name) || 'User'}
|
|
107
|
-
</p>
|
|
108
|
-
<p className="text-xs text-muted-foreground truncate">
|
|
109
|
-
{String((context.session.data as Record<string, unknown>)?.email) || ''}
|
|
110
|
-
</p>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
98
|
+
<UserMenu
|
|
99
|
+
userName={String((context.session.data as Record<string, unknown>)?.name) || 'User'}
|
|
100
|
+
userEmail={String((context.session.data as Record<string, unknown>)?.email) || ''}
|
|
101
|
+
onSignOut={onSignOut}
|
|
102
|
+
/>
|
|
114
103
|
)}
|
|
115
104
|
</nav>
|
|
116
105
|
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRouter } from 'next/navigation.js'
|
|
4
|
+
import { Button } from '../primitives/button.js'
|
|
5
|
+
|
|
6
|
+
export interface UserMenuProps {
|
|
7
|
+
userName?: string
|
|
8
|
+
userEmail?: string
|
|
9
|
+
onSignOut?: () => Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* User menu component with sign-out button
|
|
14
|
+
* Client Component
|
|
15
|
+
*/
|
|
16
|
+
export function UserMenu({ userName, userEmail, onSignOut }: UserMenuProps) {
|
|
17
|
+
const router = useRouter()
|
|
18
|
+
|
|
19
|
+
const handleSignOut = async () => {
|
|
20
|
+
if (onSignOut) {
|
|
21
|
+
await onSignOut()
|
|
22
|
+
}
|
|
23
|
+
router.push('/sign-in')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="p-4 border-t border-border bg-gradient-to-br from-primary/5 to-accent/5">
|
|
28
|
+
<div className="flex items-center space-x-3 mb-3">
|
|
29
|
+
<div className="h-9 w-9 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/25">
|
|
30
|
+
<span className="text-sm font-bold text-primary-foreground">
|
|
31
|
+
{userName?.[0]?.toUpperCase() || '?'}
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="flex-1 min-w-0">
|
|
35
|
+
<p className="text-sm font-medium truncate">{userName || 'User'}</p>
|
|
36
|
+
<p className="text-xs text-muted-foreground truncate">{userEmail || ''}</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<Button onClick={handleSignOut} variant="outline" size="sm" className="w-full text-sm">
|
|
40
|
+
Sign Out
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { Input } from '../../primitives/input.js'
|
|
4
|
+
import { Textarea } from '../../primitives/textarea.js'
|
|
4
5
|
import { Label } from '../../primitives/label.js'
|
|
5
6
|
import { cn } from '../../lib/utils.js'
|
|
6
7
|
|
|
@@ -14,6 +15,7 @@ export interface TextFieldProps {
|
|
|
14
15
|
disabled?: boolean
|
|
15
16
|
required?: boolean
|
|
16
17
|
mode?: 'read' | 'edit'
|
|
18
|
+
displayMode?: 'input' | 'textarea'
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export function TextField({
|
|
@@ -26,6 +28,7 @@ export function TextField({
|
|
|
26
28
|
disabled,
|
|
27
29
|
required,
|
|
28
30
|
mode = 'edit',
|
|
31
|
+
displayMode = 'input',
|
|
29
32
|
}: TextFieldProps) {
|
|
30
33
|
if (mode === 'read') {
|
|
31
34
|
return (
|
|
@@ -36,16 +39,18 @@ export function TextField({
|
|
|
36
39
|
)
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
const InputComponent = displayMode === 'textarea' ? Textarea : Input
|
|
43
|
+
|
|
39
44
|
return (
|
|
40
45
|
<div className="space-y-2">
|
|
41
46
|
<Label htmlFor={name}>
|
|
42
47
|
{label}
|
|
43
48
|
{required && <span className="text-destructive ml-1">*</span>}
|
|
44
49
|
</Label>
|
|
45
|
-
<
|
|
50
|
+
<InputComponent
|
|
46
51
|
id={name}
|
|
47
52
|
name={name}
|
|
48
|
-
type=
|
|
53
|
+
type={displayMode === 'input' ? 'text' : undefined}
|
|
49
54
|
value={value || ''}
|
|
50
55
|
onChange={(e) => onChange(e.target.value)}
|
|
51
56
|
placeholder={placeholder}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { AdminUI } from './components/AdminUI.js'
|
|
3
3
|
export { Dashboard } from './components/Dashboard.js'
|
|
4
4
|
export { Navigation } from './components/Navigation.js'
|
|
5
|
+
export { UserMenu } from './components/UserMenu.js'
|
|
5
6
|
export { ListView } from './components/ListView.js'
|
|
6
7
|
export { ListViewClient } from './components/ListViewClient.js'
|
|
7
8
|
export { ItemForm } from './components/ItemForm.js'
|
|
@@ -29,6 +30,7 @@ export {
|
|
|
29
30
|
export type { AdminUIProps } from './components/AdminUI.js'
|
|
30
31
|
export type { DashboardProps } from './components/Dashboard.js'
|
|
31
32
|
export type { NavigationProps } from './components/Navigation.js'
|
|
33
|
+
export type { UserMenuProps } from './components/UserMenu.js'
|
|
32
34
|
export type { ListViewProps } from './components/ListView.js'
|
|
33
35
|
export type { ListViewClientProps } from './components/ListViewClient.js'
|
|
34
36
|
export type { ItemFormProps } from './components/ItemForm.js'
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Browser Tests
|
|
2
|
+
|
|
3
|
+
This directory contains UI tests that run in a real browser environment using Vitest Browser Mode with Playwright.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Browser tests provide more realistic testing by running tests in actual browser environments (Chromium, Firefox, or WebKit). These tests can verify:
|
|
8
|
+
|
|
9
|
+
- Real browser interactions (clicks, keyboard input, focus management)
|
|
10
|
+
- Visual rendering and CSS behavior
|
|
11
|
+
- Accessibility features
|
|
12
|
+
- Browser-specific bugs
|
|
13
|
+
|
|
14
|
+
## Running Browser Tests
|
|
15
|
+
|
|
16
|
+
### Prerequisites
|
|
17
|
+
|
|
18
|
+
1. Install Playwright browsers:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx playwright install chromium
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
2. Ensure you have a display server available (for headless: false mode)
|
|
25
|
+
|
|
26
|
+
### Commands
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Run browser tests in headless mode
|
|
30
|
+
pnpm test:browser
|
|
31
|
+
|
|
32
|
+
# Run browser tests with UI
|
|
33
|
+
pnpm test:browser:ui
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Configuration
|
|
37
|
+
|
|
38
|
+
Browser tests are configured in `vitest.config.ts`:
|
|
39
|
+
|
|
40
|
+
- **Enabled**: Only when `BROWSER_TEST=true` environment variable is set
|
|
41
|
+
- **Browser**: Chromium (via Playwright)
|
|
42
|
+
- **Headless**: true (can be set to false for debugging)
|
|
43
|
+
- **Screenshot on failure**: Enabled
|
|
44
|
+
|
|
45
|
+
## Test Structure
|
|
46
|
+
|
|
47
|
+
Browser tests follow this structure:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { describe, it, expect } from 'vitest'
|
|
51
|
+
import { render, screen } from '@testing-library/react'
|
|
52
|
+
import { userEvent } from 'vitest/browser'
|
|
53
|
+
import { MyComponent } from '../../../src/components/MyComponent.js'
|
|
54
|
+
|
|
55
|
+
describe('MyComponent (Browser)', () => {
|
|
56
|
+
it('should handle real browser interactions', async () => {
|
|
57
|
+
render(<MyComponent />)
|
|
58
|
+
|
|
59
|
+
const button = screen.getByRole('button')
|
|
60
|
+
await userEvent.click(button)
|
|
61
|
+
|
|
62
|
+
expect(button).toHaveAttribute('aria-pressed', 'true')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Test Categories
|
|
68
|
+
|
|
69
|
+
### Primitives
|
|
70
|
+
|
|
71
|
+
- **Button.browser.test.tsx**: Tests button variants, keyboard navigation, focus states
|
|
72
|
+
- **Dialog.browser.test.tsx**: Tests modal dialogs, focus trapping, keyboard shortcuts (Escape)
|
|
73
|
+
|
|
74
|
+
### Fields
|
|
75
|
+
|
|
76
|
+
- **TextField.browser.test.tsx**: Tests text input, paste, special characters, focus/blur
|
|
77
|
+
- **CheckboxField.browser.test.tsx**: Tests checkbox toggling, keyboard (Space), label clicks
|
|
78
|
+
- **SelectField.browser.test.tsx**: Tests dropdown behavior, keyboard navigation (arrows)
|
|
79
|
+
|
|
80
|
+
## Browser vs Regular Tests
|
|
81
|
+
|
|
82
|
+
| Aspect | Regular Tests (Happy DOM) | Browser Tests |
|
|
83
|
+
| --------------------- | ------------------------- | -------------- |
|
|
84
|
+
| **Speed** | Fast (~500ms) | Slower (~5s) |
|
|
85
|
+
| **Environment** | Simulated DOM | Real browser |
|
|
86
|
+
| **User Interactions** | Simulated | Real events |
|
|
87
|
+
| **Visual Testing** | No | Yes |
|
|
88
|
+
| **CI/CD** | Easy | Requires setup |
|
|
89
|
+
|
|
90
|
+
## When to Use Browser Tests
|
|
91
|
+
|
|
92
|
+
Use browser tests for:
|
|
93
|
+
|
|
94
|
+
- Complex user interactions (drag-drop, keyboard navigation)
|
|
95
|
+
- Focus management and accessibility
|
|
96
|
+
- Browser-specific features
|
|
97
|
+
- Visual regression testing
|
|
98
|
+
- Issues that only reproduce in real browsers
|
|
99
|
+
|
|
100
|
+
Use regular tests (Happy DOM) for:
|
|
101
|
+
|
|
102
|
+
- Unit testing components
|
|
103
|
+
- Testing props and state
|
|
104
|
+
- Fast feedback during development
|
|
105
|
+
- Most UI logic
|
|
106
|
+
|
|
107
|
+
## CI/CD Setup
|
|
108
|
+
|
|
109
|
+
For CI/CD environments, ensure:
|
|
110
|
+
|
|
111
|
+
1. **Display Server**: Use `xvfb` or run in headless mode
|
|
112
|
+
2. **Playwright Installation**: Include `npx playwright install --with-deps` in CI setup
|
|
113
|
+
3. **Browser Binaries**: Ensure chromium/firefox/webkit are available
|
|
114
|
+
|
|
115
|
+
Example GitHub Actions:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
- name: Install Playwright
|
|
119
|
+
run: npx playwright install --with-deps chromium
|
|
120
|
+
|
|
121
|
+
- name: Run browser tests
|
|
122
|
+
run: pnpm test:browser
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Troubleshooting
|
|
126
|
+
|
|
127
|
+
### "Browser connection was closed"
|
|
128
|
+
|
|
129
|
+
- **Cause**: No display server available
|
|
130
|
+
- **Solution**: Run in headless mode or use xvfb
|
|
131
|
+
|
|
132
|
+
### "vitest/browser can be imported only inside Browser Mode"
|
|
133
|
+
|
|
134
|
+
- **Cause**: Browser tests running in regular test mode
|
|
135
|
+
- **Solution**: Ensure tests are in `tests/browser/` directory and use `pnpm test:browser`
|
|
136
|
+
|
|
137
|
+
### Playwright installation errors
|
|
138
|
+
|
|
139
|
+
- **Cause**: Missing system dependencies
|
|
140
|
+
- **Solution**: Run `npx playwright install --with-deps chromium`
|
|
141
|
+
|
|
142
|
+
## Development Tips
|
|
143
|
+
|
|
144
|
+
1. **Debugging**: Set `headless: false` in vitest.config.ts to see the browser
|
|
145
|
+
2. **Screenshots**: Failed tests automatically capture screenshots
|
|
146
|
+
3. **Slow Tests**: Use `{ timeout: 10000 }` for tests that need more time
|
|
147
|
+
4. **Browser DevTools**: Set `slowMo: 100` to slow down interactions for debugging
|
|
148
|
+
|
|
149
|
+
## Future Enhancements
|
|
150
|
+
|
|
151
|
+
- [ ] Add visual regression testing with screenshots
|
|
152
|
+
- [ ] Test in multiple browsers (Firefox, WebKit)
|
|
153
|
+
- [ ] Add E2E tests for complete user flows
|
|
154
|
+
- [ ] Integrate with Percy or similar visual testing service
|