create-fullstack-scaffold 0.1.1 → 0.2.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/dist/cli/index.js +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- package/template/package-lock.json +0 -14554
|
@@ -0,0 +1,1824 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
/**
|
|
3
|
+
* Visual Screenshot Capture
|
|
4
|
+
*
|
|
5
|
+
* Captures screenshots of all key pages for visual verification.
|
|
6
|
+
* Screenshots are saved to playwright-artifacts/screenshots/ and
|
|
7
|
+
* uploaded as CI artifacts for review.
|
|
8
|
+
*
|
|
9
|
+
* Two modes:
|
|
10
|
+
* 1. Template screenshots (existing) — screenshots the template's own pages
|
|
11
|
+
* 2. Per-Preset Gallery — scaffolds each preset, starts dev server,
|
|
12
|
+
* captures all pages in user-flow order with login + data seeding,
|
|
13
|
+
* generates HTML gallery
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* - CI: Screenshots uploaded as artifacts automatically
|
|
17
|
+
* - Local: Check template/playwright-artifacts/gallery/
|
|
18
|
+
*
|
|
19
|
+
* Tagged @slow — these tests take time due to scaffolding + dev server startup.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { test, expect } from '@playwright/test'
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { execSync, spawn } from 'node:child_process'
|
|
26
|
+
import os from 'node:os'
|
|
27
|
+
|
|
28
|
+
// @ts-expect-error — TS config doesn't resolve .ts imports for E2E
|
|
29
|
+
import { getPreset } from '../../modules.config.ts'
|
|
30
|
+
|
|
31
|
+
const ARTIFACTS_DIR = path.resolve(import.meta.dirname, '../../playwright-artifacts/screenshots')
|
|
32
|
+
const GALLERY_DIR = path.resolve(import.meta.dirname, '../../playwright-artifacts/gallery')
|
|
33
|
+
const TEMPLATE_ROOT = path.resolve(import.meta.dirname, '../..')
|
|
34
|
+
|
|
35
|
+
function getBaseUrl(): string {
|
|
36
|
+
return process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:3010'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true })
|
|
40
|
+
fs.mkdirSync(GALLERY_DIR, { recursive: true })
|
|
41
|
+
|
|
42
|
+
// ─── Utility Functions ──────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
async function capturePage(
|
|
45
|
+
page: import('@playwright/test').Page,
|
|
46
|
+
name: string,
|
|
47
|
+
outputDir?: string
|
|
48
|
+
) {
|
|
49
|
+
const dir = outputDir || ARTIFACTS_DIR
|
|
50
|
+
const screenshotPath = path.join(dir, `${name}.png`)
|
|
51
|
+
const buffer = await page.screenshot({ fullPage: true })
|
|
52
|
+
fs.writeFileSync(screenshotPath, buffer)
|
|
53
|
+
console.log(` 📸 Saved: ${screenshotPath}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function checkConsoleErrors(page: import('@playwright/test').Page): Promise<string[]> {
|
|
57
|
+
const errors: string[] = []
|
|
58
|
+
page.on('console', msg => {
|
|
59
|
+
if (msg.type() === 'error') {
|
|
60
|
+
errors.push(msg.text())
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
return errors
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Login Helpers ──────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
interface AuthTokens {
|
|
69
|
+
clientToken: string
|
|
70
|
+
clientUser: { id: string; email: string; username: string; role: string }
|
|
71
|
+
adminToken: string
|
|
72
|
+
adminUser: { id: string; username: string; email: string; role: string }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function registerAndLoginUsers(
|
|
76
|
+
page: import('@playwright/test').Page,
|
|
77
|
+
baseUrl: string,
|
|
78
|
+
presetId: string
|
|
79
|
+
): Promise<AuthTokens> {
|
|
80
|
+
const preset = getPreset(presetId)
|
|
81
|
+
const modules = new Set(preset?.modules ?? [])
|
|
82
|
+
const hasAuth = modules.has('auth')
|
|
83
|
+
const hasAdmin = modules.has('admin')
|
|
84
|
+
|
|
85
|
+
let clientToken = ''
|
|
86
|
+
const clientUser = {
|
|
87
|
+
id: '1',
|
|
88
|
+
username: 'screenshotuser',
|
|
89
|
+
email: 'screenshot@test.com',
|
|
90
|
+
role: 'developer',
|
|
91
|
+
}
|
|
92
|
+
let adminToken = ''
|
|
93
|
+
const adminUser = {
|
|
94
|
+
id: '2',
|
|
95
|
+
username: 'superadmin',
|
|
96
|
+
email: 'admin@test.com',
|
|
97
|
+
role: 'super_admin',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Client auth: use /api/auth/* if auth module present
|
|
101
|
+
if (hasAuth) {
|
|
102
|
+
await page.request
|
|
103
|
+
.post(`${baseUrl}/api/auth/register`, {
|
|
104
|
+
data: { username: 'screenshotuser', email: 'screenshot@test.com', password: 'test123456' },
|
|
105
|
+
})
|
|
106
|
+
.catch(() => {})
|
|
107
|
+
|
|
108
|
+
// Retry login up to 3 times to handle transient ECONNREFUSED (IPv4/IPv6 mismatch)
|
|
109
|
+
let clientLoginRes: Awaited<ReturnType<typeof page.request.post>> | null = null
|
|
110
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
111
|
+
try {
|
|
112
|
+
clientLoginRes = await page.request.post(`${baseUrl}/api/auth/login`, {
|
|
113
|
+
data: { account: 'screenshotuser', password: 'test123456' },
|
|
114
|
+
timeout: 5000,
|
|
115
|
+
})
|
|
116
|
+
if (clientLoginRes.ok()) break
|
|
117
|
+
} catch {
|
|
118
|
+
if (attempt < 2) await new Promise(r => setTimeout(r, 2000))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!clientLoginRes) throw new Error('Failed to login after 3 retries')
|
|
122
|
+
const clientLoginBody = await clientLoginRes.json()
|
|
123
|
+
clientToken = clientLoginBody.data?.token ?? clientLoginBody.data?.profile?.token ?? ''
|
|
124
|
+
const profile = clientLoginBody.data?.profile ?? clientLoginBody.data ?? {}
|
|
125
|
+
if (profile.id) clientUser.id = profile.id
|
|
126
|
+
if (profile.username) clientUser.username = profile.username
|
|
127
|
+
if (profile.email) clientUser.email = profile.email
|
|
128
|
+
if (profile.role) clientUser.role = profile.role
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Admin auth: always use dev token (more reliable than register/login)
|
|
132
|
+
// Dev tokens are enabled in dev/test environments and provide guaranteed super_admin access
|
|
133
|
+
if (hasAdmin) {
|
|
134
|
+
adminToken = 'super-admin-token'
|
|
135
|
+
adminUser.id = 'super-admin-1'
|
|
136
|
+
adminUser.username = 'superadmin'
|
|
137
|
+
adminUser.email = 'superadmin@example.com'
|
|
138
|
+
adminUser.role = 'super_admin'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { clientToken, clientUser, adminToken, adminUser }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function setClientAuth(
|
|
145
|
+
page: import('@playwright/test').Page,
|
|
146
|
+
token: string,
|
|
147
|
+
user: AuthTokens['clientUser']
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
await page.evaluate(
|
|
150
|
+
({ token: t, user: u }) => {
|
|
151
|
+
const authData = {
|
|
152
|
+
state: { token: t, isAuthenticated: true, user: u },
|
|
153
|
+
version: 0,
|
|
154
|
+
}
|
|
155
|
+
// apiClient reads from 'auth-token' key (see src/client/services/apiClient.ts)
|
|
156
|
+
localStorage.setItem('auth-token', JSON.stringify(authData))
|
|
157
|
+
},
|
|
158
|
+
{ token, user }
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function setAdminAuth(
|
|
163
|
+
page: import('@playwright/test').Page,
|
|
164
|
+
token: string,
|
|
165
|
+
user: AuthTokens['adminUser']
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
await page.evaluate(
|
|
168
|
+
({ token: t, user: u }) => {
|
|
169
|
+
localStorage.setItem(
|
|
170
|
+
'admin-storage',
|
|
171
|
+
JSON.stringify({
|
|
172
|
+
state: { token: t, isAuthenticated: true, user: u },
|
|
173
|
+
version: 0,
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
},
|
|
177
|
+
{ token, user }
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Data Seeding ───────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
async function seedTodos(
|
|
184
|
+
page: import('@playwright/test').Page,
|
|
185
|
+
baseUrl: string,
|
|
186
|
+
token: string
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
|
|
189
|
+
const todos = [
|
|
190
|
+
{ title: 'Buy groceries for the week', completed: false },
|
|
191
|
+
{ title: 'Read "Designing Data-Intensive Applications"', completed: false },
|
|
192
|
+
{ title: 'Review pull request #42', completed: true },
|
|
193
|
+
{ title: 'Set up CI/CD pipeline', completed: false },
|
|
194
|
+
{ title: 'Write unit tests for auth module', completed: true },
|
|
195
|
+
]
|
|
196
|
+
for (const todo of todos) {
|
|
197
|
+
await page.request.post(`${baseUrl}/api/todos`, { data: todo, headers })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function seedNotifications(
|
|
202
|
+
page: import('@playwright/test').Page,
|
|
203
|
+
baseUrl: string
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
const notifications = [
|
|
206
|
+
{
|
|
207
|
+
type: 'success',
|
|
208
|
+
title: 'Deployment Successful',
|
|
209
|
+
message: 'Your application has been deployed to production.',
|
|
210
|
+
},
|
|
211
|
+
{ type: 'info', title: 'New Comment', message: 'John Doe commented on your pull request.' },
|
|
212
|
+
{
|
|
213
|
+
type: 'warning',
|
|
214
|
+
title: 'Storage Running Low',
|
|
215
|
+
message: 'Your cloud storage is 85% full. Consider upgrading.',
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
type: 'error',
|
|
219
|
+
title: 'Build Failed',
|
|
220
|
+
message: 'The CI build for branch feature/auth failed.',
|
|
221
|
+
},
|
|
222
|
+
]
|
|
223
|
+
for (const n of notifications) {
|
|
224
|
+
await page.request.post(`${baseUrl}/api/notifications`, { data: n })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function seedPlugins(
|
|
229
|
+
page: import('@playwright/test').Page,
|
|
230
|
+
baseUrl: string,
|
|
231
|
+
token: string,
|
|
232
|
+
adminToken?: string
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
|
|
235
|
+
const adminHeaders: Record<string, string> = adminToken
|
|
236
|
+
? { Authorization: `Bearer ${adminToken}` }
|
|
237
|
+
: {}
|
|
238
|
+
const plugins = [
|
|
239
|
+
{
|
|
240
|
+
name: 'Code Formatter Pro',
|
|
241
|
+
slug: 'code-formatter-pro',
|
|
242
|
+
description:
|
|
243
|
+
'Advanced code formatting with support for 50+ languages. Customizable rules, team presets, and auto-format on save.',
|
|
244
|
+
tags: ['formatting', 'productivity'],
|
|
245
|
+
license: 'MIT',
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: 'Git Lens',
|
|
249
|
+
slug: 'git-lens',
|
|
250
|
+
description:
|
|
251
|
+
'Supercharge Git within your editor. See commit blame, file history, and branch comparisons at a glance.',
|
|
252
|
+
tags: ['git', 'version-control'],
|
|
253
|
+
license: 'MIT',
|
|
254
|
+
featured: true,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'Theme Studio',
|
|
258
|
+
slug: 'theme-studio',
|
|
259
|
+
description:
|
|
260
|
+
'Create and share custom editor themes with a visual designer. Import from VS Code, export everywhere.',
|
|
261
|
+
tags: ['themes', 'customization'],
|
|
262
|
+
},
|
|
263
|
+
]
|
|
264
|
+
// Create plugins (status defaults to 'pending')
|
|
265
|
+
for (const p of plugins) {
|
|
266
|
+
await page.request.post(`${baseUrl}/api/plugins`, { data: p, headers }).catch(() => {})
|
|
267
|
+
}
|
|
268
|
+
// Approve all plugins so they appear on client pages (GET /api/plugins defaults to status='approved')
|
|
269
|
+
if (adminToken) {
|
|
270
|
+
for (const p of plugins) {
|
|
271
|
+
await page.request
|
|
272
|
+
.put(`${baseUrl}/api/plugins/${p.slug}/approve`, { headers: adminHeaders })
|
|
273
|
+
.catch(() => {})
|
|
274
|
+
}
|
|
275
|
+
// Toggle featured for Git Lens (approve doesn't set featured flag)
|
|
276
|
+
await page.request
|
|
277
|
+
.put(`${baseUrl}/api/plugins/git-lens/feature`, { headers: adminHeaders })
|
|
278
|
+
.catch(() => {})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function seedOrders(
|
|
283
|
+
page: import('@playwright/test').Page,
|
|
284
|
+
baseUrl: string,
|
|
285
|
+
adminToken: string
|
|
286
|
+
): Promise<void> {
|
|
287
|
+
const headers: Record<string, string> = adminToken
|
|
288
|
+
? { Authorization: `Bearer ${adminToken}` }
|
|
289
|
+
: {}
|
|
290
|
+
const orders = [
|
|
291
|
+
{
|
|
292
|
+
customerName: 'Alice Johnson',
|
|
293
|
+
customerEmail: 'alice@example.com',
|
|
294
|
+
productName: 'Wireless Headphones',
|
|
295
|
+
amount: 89.99,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
customerName: 'Bob Smith',
|
|
299
|
+
customerEmail: 'bob@example.com',
|
|
300
|
+
productName: 'Mechanical Keyboard',
|
|
301
|
+
amount: 149.99,
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
customerName: 'Carol Williams',
|
|
305
|
+
customerEmail: 'carol@example.com',
|
|
306
|
+
productName: 'USB-C Hub',
|
|
307
|
+
amount: 45.5,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
customerName: 'David Brown',
|
|
311
|
+
customerEmail: 'david@example.com',
|
|
312
|
+
productName: '4K Monitor',
|
|
313
|
+
amount: 399.0,
|
|
314
|
+
},
|
|
315
|
+
]
|
|
316
|
+
for (const order of orders) {
|
|
317
|
+
await page.request.post(`${baseUrl}/api/orders`, { data: order, headers }).catch(() => {})
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function seedTickets(
|
|
322
|
+
page: import('@playwright/test').Page,
|
|
323
|
+
baseUrl: string,
|
|
324
|
+
_clientToken: string,
|
|
325
|
+
adminToken: string
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const headers: Record<string, string> = adminToken
|
|
328
|
+
? { Authorization: `Bearer ${adminToken}` }
|
|
329
|
+
: {}
|
|
330
|
+
const tickets = [
|
|
331
|
+
{
|
|
332
|
+
customerName: 'Alice Johnson',
|
|
333
|
+
customerEmail: 'alice@example.com',
|
|
334
|
+
subject: 'Cannot connect to VPN after update',
|
|
335
|
+
description:
|
|
336
|
+
'After the latest firmware update, my VPN client fails to connect. I have tried reinstalling but the issue persists.',
|
|
337
|
+
category: 'technical',
|
|
338
|
+
priority: 'high',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
customerName: 'Bob Smith',
|
|
342
|
+
customerEmail: 'bob@example.com',
|
|
343
|
+
subject: 'Billing discrepancy on invoice #1042',
|
|
344
|
+
description:
|
|
345
|
+
'My invoice shows a charge for 2 items but I only ordered 1. Please correct this.',
|
|
346
|
+
category: 'billing',
|
|
347
|
+
priority: 'medium',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
customerName: 'Carol Williams',
|
|
351
|
+
customerEmail: 'carol@example.com',
|
|
352
|
+
subject: 'Feature request: Dark mode for dashboard',
|
|
353
|
+
description:
|
|
354
|
+
'It would be great to have a dark mode option for the admin dashboard to reduce eye strain during late-night work.',
|
|
355
|
+
category: 'feature_request',
|
|
356
|
+
priority: 'low',
|
|
357
|
+
},
|
|
358
|
+
]
|
|
359
|
+
const createdIds: string[] = []
|
|
360
|
+
for (const ticket of tickets) {
|
|
361
|
+
const res = await page.request
|
|
362
|
+
.post(`${baseUrl}/api/tickets`, { data: ticket, headers })
|
|
363
|
+
.catch(() => null)
|
|
364
|
+
if (res) {
|
|
365
|
+
try {
|
|
366
|
+
const body = await res.json()
|
|
367
|
+
if (body.success && body.data?.id) createdIds.push(body.data.id)
|
|
368
|
+
} catch {
|
|
369
|
+
// ignore
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Add a reply to the first ticket
|
|
374
|
+
if (createdIds.length > 0) {
|
|
375
|
+
await page.request
|
|
376
|
+
.post(`${baseUrl}/api/tickets/${createdIds[0]}/reply`, {
|
|
377
|
+
data: {
|
|
378
|
+
content: 'Thank you for reporting this. We are looking into the VPN connectivity issue.',
|
|
379
|
+
author: 'Support Team',
|
|
380
|
+
},
|
|
381
|
+
headers,
|
|
382
|
+
})
|
|
383
|
+
.catch(() => {})
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function seedDisputes(
|
|
388
|
+
page: import('@playwright/test').Page,
|
|
389
|
+
baseUrl: string,
|
|
390
|
+
_clientToken: string,
|
|
391
|
+
adminToken: string
|
|
392
|
+
): Promise<void> {
|
|
393
|
+
const headers: Record<string, string> = adminToken
|
|
394
|
+
? { Authorization: `Bearer ${adminToken}` }
|
|
395
|
+
: {}
|
|
396
|
+
const disputes = [
|
|
397
|
+
{
|
|
398
|
+
orderId: 'order-001',
|
|
399
|
+
orderNo: 'ORD-2024-001',
|
|
400
|
+
customerName: 'Alice Johnson',
|
|
401
|
+
customerEmail: 'alice@example.com',
|
|
402
|
+
type: 'product_quality',
|
|
403
|
+
description:
|
|
404
|
+
'The headphones I received have a crackling sound in the left ear after 2 days of use.',
|
|
405
|
+
amount: 89.99,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
orderId: 'order-002',
|
|
409
|
+
orderNo: 'ORD-2024-002',
|
|
410
|
+
customerName: 'Bob Smith',
|
|
411
|
+
customerEmail: 'bob@example.com',
|
|
412
|
+
type: 'refund',
|
|
413
|
+
description: 'I requested a cancellation within 30 minutes but the order was still shipped.',
|
|
414
|
+
amount: 149.99,
|
|
415
|
+
},
|
|
416
|
+
]
|
|
417
|
+
for (const dispute of disputes) {
|
|
418
|
+
await page.request.post(`${baseUrl}/api/disputes`, { data: dispute, headers }).catch(() => {})
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function seedContents(
|
|
423
|
+
page: import('@playwright/test').Page,
|
|
424
|
+
baseUrl: string,
|
|
425
|
+
adminToken: string
|
|
426
|
+
): Promise<void> {
|
|
427
|
+
const headers: Record<string, string> = adminToken
|
|
428
|
+
? { Authorization: `Bearer ${adminToken}` }
|
|
429
|
+
: {}
|
|
430
|
+
const contents = [
|
|
431
|
+
{
|
|
432
|
+
title: 'Getting Started with Biomimic',
|
|
433
|
+
content:
|
|
434
|
+
'Welcome to Biomimic! This guide will walk you through setting up your first project, configuring modules, and deploying to production. We cover everything from the CLI scaffold command to customizing your admin panel.',
|
|
435
|
+
category: 'tutorial',
|
|
436
|
+
tags: ['getting-started', 'tutorial', 'beginner'],
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
title: 'Version 2.0 Release Notes',
|
|
440
|
+
content:
|
|
441
|
+
'We are excited to announce Biomimic 2.0! This release includes a brand-new plugin marketplace, improved RBAC with audit logging, and Cloudflare Workers support. Read on for the full changelog.',
|
|
442
|
+
category: 'announcement',
|
|
443
|
+
tags: ['release', 'v2'],
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
title: 'Building a Plugin Marketplace',
|
|
447
|
+
content:
|
|
448
|
+
'Learn how to create, publish, and monetize plugins for the xbrowser ecosystem. This tutorial covers the plugin API, review process, and best practices for plugin development.',
|
|
449
|
+
category: 'article',
|
|
450
|
+
tags: ['plugins', 'marketplace', 'development'],
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
title: 'Privacy Policy Update — December 2024',
|
|
454
|
+
content:
|
|
455
|
+
'We have updated our privacy policy to reflect changes in data processing. Key changes include improved data retention controls and new cookie consent options.',
|
|
456
|
+
category: 'policy',
|
|
457
|
+
tags: ['privacy', 'legal'],
|
|
458
|
+
},
|
|
459
|
+
]
|
|
460
|
+
const createdIds: string[] = []
|
|
461
|
+
for (const c of contents) {
|
|
462
|
+
const res = await page.request
|
|
463
|
+
.post(`${baseUrl}/api/contents`, { data: c, headers })
|
|
464
|
+
.catch(() => null)
|
|
465
|
+
if (res) {
|
|
466
|
+
try {
|
|
467
|
+
const body = await res.json()
|
|
468
|
+
if (body.success && body.data?.id) createdIds.push(body.data.id)
|
|
469
|
+
} catch {
|
|
470
|
+
// ignore
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Publish the first 3 items so they appear on public content pages
|
|
475
|
+
for (let i = 0; i < Math.min(createdIds.length, 3); i++) {
|
|
476
|
+
await page.request
|
|
477
|
+
.put(`${baseUrl}/api/contents/${createdIds[i]}/publish`, { headers })
|
|
478
|
+
.catch(() => {})
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function seedAllData(
|
|
483
|
+
page: import('@playwright/test').Page,
|
|
484
|
+
baseUrl: string,
|
|
485
|
+
tokens: AuthTokens,
|
|
486
|
+
presetId: string
|
|
487
|
+
): Promise<void> {
|
|
488
|
+
const preset = getPreset(presetId)
|
|
489
|
+
const modules = new Set(preset?.modules ?? [])
|
|
490
|
+
|
|
491
|
+
if (modules.has('todos')) await seedTodos(page, baseUrl, tokens.clientToken)
|
|
492
|
+
if (modules.has('notifications')) await seedNotifications(page, baseUrl)
|
|
493
|
+
if (modules.has('plugin')) await seedPlugins(page, baseUrl, tokens.clientToken, tokens.adminToken)
|
|
494
|
+
if (modules.has('order')) await seedOrders(page, baseUrl, tokens.adminToken)
|
|
495
|
+
if (modules.has('ticket')) await seedTickets(page, baseUrl, tokens.clientToken, tokens.adminToken)
|
|
496
|
+
if (modules.has('dispute'))
|
|
497
|
+
await seedDisputes(page, baseUrl, tokens.clientToken, tokens.adminToken)
|
|
498
|
+
if (modules.has('content')) await seedContents(page, baseUrl, tokens.adminToken)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ─── CLI Terminal Screenshot ────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
function generateTerminalHtml(command: string, stdout: string, stderr: string): string {
|
|
504
|
+
const esc = (s: string) =>
|
|
505
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
506
|
+
|
|
507
|
+
const lines = (stdout + (stderr ? '\n' + stderr : ''))
|
|
508
|
+
.split('\n')
|
|
509
|
+
.map(l => `<span class="output">${esc(l)}</span>`)
|
|
510
|
+
.join('\n')
|
|
511
|
+
|
|
512
|
+
return `<!DOCTYPE html>
|
|
513
|
+
<html><head><meta charset="UTF-8"><style>
|
|
514
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
515
|
+
body { background:#1e1e2e; color:#cdd6f4; font-family:'Menlo','Monaco','Courier New',monospace; font-size:13px; line-height:1.6; padding:20px; min-height:100vh; }
|
|
516
|
+
.terminal { background:#181825; border-radius:12px; padding:16px; border:1px solid #313244; max-width:900px; margin:0 auto; }
|
|
517
|
+
.titlebar { display:flex; gap:8px; margin-bottom:12px; padding-bottom:8px; border-bottom:1px solid #313244; }
|
|
518
|
+
.dot { width:12px; height:12px; border-radius:50%; }
|
|
519
|
+
.dot.red { background:#f38ba8; } .dot.yellow { background:#f9e2af; } .dot.green { background:#a6e3a1; }
|
|
520
|
+
.prompt { color:#a6e3a1; font-weight:bold; }
|
|
521
|
+
.cmd { color:#89b4fa; }
|
|
522
|
+
.output { color:#cdd6f4; }
|
|
523
|
+
pre { white-space:pre-wrap; word-break:break-all; margin:0; }
|
|
524
|
+
</style></head>
|
|
525
|
+
<body>
|
|
526
|
+
<div class="terminal">
|
|
527
|
+
<div class="titlebar"><span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span></div>
|
|
528
|
+
<pre><span class="prompt">$</span> <span class="cmd">${esc(command)}</span>
|
|
529
|
+
${lines}</pre>
|
|
530
|
+
</div>
|
|
531
|
+
</body></html>`
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function captureTerminalScreenshot(
|
|
535
|
+
command: string,
|
|
536
|
+
args: string[],
|
|
537
|
+
cwd: string,
|
|
538
|
+
outputDir: string,
|
|
539
|
+
label: string,
|
|
540
|
+
page: import('@playwright/test').Page
|
|
541
|
+
): Promise<boolean> {
|
|
542
|
+
try {
|
|
543
|
+
const result = execSync(`${command} ${args.join(' ')}`, {
|
|
544
|
+
cwd,
|
|
545
|
+
stdio: 'pipe',
|
|
546
|
+
timeout: 120_000,
|
|
547
|
+
env: { ...process.env, CI: 'true' },
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
const stdout = result.toString()
|
|
551
|
+
const terminalHtml = generateTerminalHtml(`${command} ${args.join(' ')}`, stdout, '')
|
|
552
|
+
const htmlPath = path.join(outputDir, `${label}.html`)
|
|
553
|
+
fs.writeFileSync(htmlPath, terminalHtml, 'utf-8')
|
|
554
|
+
|
|
555
|
+
await page.goto(`file://${htmlPath}`)
|
|
556
|
+
await page.waitForTimeout(300)
|
|
557
|
+
await capturePage(page, label, outputDir)
|
|
558
|
+
return true
|
|
559
|
+
} catch (err) {
|
|
560
|
+
const execErr = err as { stdout?: Buffer; stderr?: Buffer }
|
|
561
|
+
const stdout = execErr.stdout?.toString() ?? ''
|
|
562
|
+
const stderr = execErr.stderr?.toString() ?? ''
|
|
563
|
+
const terminalHtml = generateTerminalHtml(`${command} ${args.join(' ')}`, stdout, stderr)
|
|
564
|
+
const htmlPath = path.join(outputDir, `${label}.html`)
|
|
565
|
+
fs.writeFileSync(htmlPath, terminalHtml, 'utf-8')
|
|
566
|
+
|
|
567
|
+
await page.goto(`file://${htmlPath}`)
|
|
568
|
+
await page.waitForTimeout(300)
|
|
569
|
+
await capturePage(page, label, outputDir)
|
|
570
|
+
return true
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ─── Safe Screenshot with Auth ──────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
async function screenshotPage(
|
|
577
|
+
page: import('@playwright/test').Page,
|
|
578
|
+
baseUrl: string,
|
|
579
|
+
route: string,
|
|
580
|
+
label: string,
|
|
581
|
+
outputDir: string,
|
|
582
|
+
index: number,
|
|
583
|
+
options?: { waitForTimeout?: number; waitForSelector?: string }
|
|
584
|
+
): Promise<boolean> {
|
|
585
|
+
const name = `${String(index).padStart(2, '0')}-${label}`
|
|
586
|
+
const fullUrl = `${baseUrl}${route}`
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
const res = await page.goto(fullUrl, { timeout: 30_000 })
|
|
590
|
+
if (!res || (res.status() >= 400 && res.status() < 600)) {
|
|
591
|
+
console.log(` ⚠️ Skip ${name}: HTTP ${res?.status()} for ${route}`)
|
|
592
|
+
return false
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
596
|
+
|
|
597
|
+
if (options?.waitForSelector) {
|
|
598
|
+
await page.waitForSelector(options.waitForSelector, { timeout: 15_000 }).catch(() => {})
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
await page.waitForTimeout(options?.waitForTimeout ?? 800)
|
|
602
|
+
await capturePage(page, name, outputDir)
|
|
603
|
+
return true
|
|
604
|
+
} catch (err) {
|
|
605
|
+
console.log(` ⚠️ Skip ${name}: ${(err as Error).message.slice(0, 100)} for ${route}`)
|
|
606
|
+
return false
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ─── Preset Configuration ───────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
interface FlowStep {
|
|
613
|
+
route: string
|
|
614
|
+
label: string
|
|
615
|
+
section: 'cli' | 'client-public' | 'client-auth' | 'admin-public' | 'admin-auth' | 'crud'
|
|
616
|
+
waitForSelector?: string
|
|
617
|
+
waitForTimeout?: number
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
interface PresetConfig {
|
|
621
|
+
id: string
|
|
622
|
+
name: string
|
|
623
|
+
steps: FlowStep[]
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function buildStepsForPreset(presetId: string): FlowStep[] {
|
|
627
|
+
const steps: FlowStep[] = []
|
|
628
|
+
|
|
629
|
+
// Dynamically read module list from modules.config.ts
|
|
630
|
+
const preset = getPreset(presetId)
|
|
631
|
+
const modules = new Set(preset?.modules ?? [])
|
|
632
|
+
|
|
633
|
+
const hasTodos = modules.has('todos')
|
|
634
|
+
const hasChat = modules.has('chat')
|
|
635
|
+
const hasNotifications = modules.has('notifications')
|
|
636
|
+
const hasPlugins = modules.has('plugin')
|
|
637
|
+
const hasAuth = modules.has('auth')
|
|
638
|
+
const hasAdmin = modules.has('admin')
|
|
639
|
+
const hasOrders = modules.has('order')
|
|
640
|
+
const hasTickets = modules.has('ticket')
|
|
641
|
+
const hasDisputes = modules.has('dispute')
|
|
642
|
+
const hasContent = modules.has('content')
|
|
643
|
+
|
|
644
|
+
// CLI steps
|
|
645
|
+
steps.push({ route: '', label: 'cli-create', section: 'cli' })
|
|
646
|
+
|
|
647
|
+
// Client public pages
|
|
648
|
+
if (hasAuth) {
|
|
649
|
+
steps.push({ route: '/', label: 'home-page', section: 'client-public' })
|
|
650
|
+
steps.push({ route: '/register', label: 'register-page', section: 'client-public' })
|
|
651
|
+
steps.push({ route: '/login', label: 'login-page', section: 'client-public' })
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Client authenticated pages
|
|
655
|
+
if (hasTodos) {
|
|
656
|
+
steps.push({
|
|
657
|
+
route: '/todos',
|
|
658
|
+
label: 'todo-page-with-data',
|
|
659
|
+
section: 'client-auth',
|
|
660
|
+
waitForSelector: '[data-testid="todo-list"], ul, table, .todo',
|
|
661
|
+
waitForTimeout: 1200,
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (hasNotifications) {
|
|
666
|
+
steps.push({
|
|
667
|
+
route: '/notifications',
|
|
668
|
+
label: 'notifications-page-with-data',
|
|
669
|
+
section: 'client-auth',
|
|
670
|
+
waitForTimeout: 1200,
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (hasChat) {
|
|
675
|
+
steps.push({ route: '/websocket', label: 'websocket-page', section: 'client-auth' })
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (hasPlugins) {
|
|
679
|
+
steps.push({
|
|
680
|
+
route: '/plugins',
|
|
681
|
+
label: 'plugins-page-with-data',
|
|
682
|
+
section: 'client-auth',
|
|
683
|
+
waitForSelector: 'a[href^="/plugins/"]',
|
|
684
|
+
waitForTimeout: 2500,
|
|
685
|
+
})
|
|
686
|
+
steps.push({
|
|
687
|
+
route: '/plugins/code-formatter-pro',
|
|
688
|
+
label: 'plugin-detail-page',
|
|
689
|
+
section: 'client-auth',
|
|
690
|
+
waitForTimeout: 1200,
|
|
691
|
+
})
|
|
692
|
+
steps.push({
|
|
693
|
+
route: '/categories',
|
|
694
|
+
label: 'categories-page',
|
|
695
|
+
section: 'client-auth',
|
|
696
|
+
waitForTimeout: 1000,
|
|
697
|
+
})
|
|
698
|
+
steps.push({
|
|
699
|
+
route: '/search',
|
|
700
|
+
label: 'search-page',
|
|
701
|
+
section: 'client-auth',
|
|
702
|
+
waitForTimeout: 1000,
|
|
703
|
+
})
|
|
704
|
+
steps.push({ route: '/publish', label: 'publish-page', section: 'client-auth' })
|
|
705
|
+
steps.push({ route: '/developer', label: 'developer-dashboard', section: 'client-auth' })
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (hasContent) {
|
|
709
|
+
steps.push({ route: '/content', label: 'content-page', section: 'client-auth' })
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// No-auth fallback pages
|
|
713
|
+
if (!hasAuth && hasTodos) {
|
|
714
|
+
steps.unshift({ route: '/', label: 'home-page', section: 'client-public' })
|
|
715
|
+
steps.push({
|
|
716
|
+
route: '/todos',
|
|
717
|
+
label: 'todo-page-with-data',
|
|
718
|
+
section: 'client-public',
|
|
719
|
+
waitForSelector: '[data-testid="todo-list"], ul, table, .todo',
|
|
720
|
+
waitForTimeout: 1200,
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (!hasAuth && hasNotifications) {
|
|
725
|
+
steps.push({
|
|
726
|
+
route: '/notifications',
|
|
727
|
+
label: 'notifications-page',
|
|
728
|
+
section: 'client-public',
|
|
729
|
+
waitForTimeout: 1000,
|
|
730
|
+
})
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!hasAuth && hasChat) {
|
|
734
|
+
steps.push({ route: '/websocket', label: 'websocket-page', section: 'client-public' })
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Admin pages
|
|
738
|
+
if (hasAdmin) {
|
|
739
|
+
steps.push({ route: '/admin/login', label: 'admin-login-page', section: 'admin-public' })
|
|
740
|
+
steps.push({
|
|
741
|
+
route: '/admin/dashboard',
|
|
742
|
+
label: 'admin-dashboard',
|
|
743
|
+
section: 'admin-auth',
|
|
744
|
+
waitForTimeout: 2000,
|
|
745
|
+
})
|
|
746
|
+
steps.push({
|
|
747
|
+
route: '/admin/users',
|
|
748
|
+
label: 'admin-users',
|
|
749
|
+
section: 'admin-auth',
|
|
750
|
+
waitForTimeout: 1500,
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (hasOrders) {
|
|
755
|
+
steps.push({
|
|
756
|
+
route: '/admin/orders',
|
|
757
|
+
label: 'admin-orders',
|
|
758
|
+
section: 'admin-auth',
|
|
759
|
+
waitForTimeout: 1500,
|
|
760
|
+
})
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (hasTickets) {
|
|
764
|
+
steps.push({
|
|
765
|
+
route: '/admin/tickets',
|
|
766
|
+
label: 'admin-tickets',
|
|
767
|
+
section: 'admin-auth',
|
|
768
|
+
waitForTimeout: 1500,
|
|
769
|
+
})
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (hasDisputes) {
|
|
773
|
+
steps.push({
|
|
774
|
+
route: '/admin/disputes',
|
|
775
|
+
label: 'admin-disputes',
|
|
776
|
+
section: 'admin-auth',
|
|
777
|
+
waitForTimeout: 1500,
|
|
778
|
+
})
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (hasContent) {
|
|
782
|
+
steps.push({
|
|
783
|
+
route: '/admin/content',
|
|
784
|
+
label: 'admin-content',
|
|
785
|
+
section: 'admin-auth',
|
|
786
|
+
waitForTimeout: 1500,
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (hasPlugins) {
|
|
791
|
+
steps.push({
|
|
792
|
+
route: '/admin/plugins',
|
|
793
|
+
label: 'admin-plugins',
|
|
794
|
+
section: 'admin-auth',
|
|
795
|
+
waitForTimeout: 1500,
|
|
796
|
+
})
|
|
797
|
+
steps.push({
|
|
798
|
+
route: '/admin/plugins/review',
|
|
799
|
+
label: 'admin-plugin-review',
|
|
800
|
+
section: 'admin-auth',
|
|
801
|
+
waitForTimeout: 1500,
|
|
802
|
+
})
|
|
803
|
+
steps.push({
|
|
804
|
+
route: '/admin/categories',
|
|
805
|
+
label: 'admin-categories',
|
|
806
|
+
section: 'admin-auth',
|
|
807
|
+
waitForTimeout: 1500,
|
|
808
|
+
})
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (hasAdmin) {
|
|
812
|
+
steps.push({
|
|
813
|
+
route: '/admin/system/settings',
|
|
814
|
+
label: 'admin-settings',
|
|
815
|
+
section: 'admin-auth',
|
|
816
|
+
waitForTimeout: 1500,
|
|
817
|
+
})
|
|
818
|
+
steps.push({
|
|
819
|
+
route: '/admin/system/permissions',
|
|
820
|
+
label: 'admin-permissions',
|
|
821
|
+
section: 'admin-auth',
|
|
822
|
+
waitForTimeout: 1500,
|
|
823
|
+
})
|
|
824
|
+
steps.push({
|
|
825
|
+
route: '/admin/system/roles',
|
|
826
|
+
label: 'admin-roles',
|
|
827
|
+
section: 'admin-auth',
|
|
828
|
+
waitForTimeout: 1500,
|
|
829
|
+
})
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// CRUD operation screenshots (interactive UI flows)
|
|
833
|
+
if (hasTodos) {
|
|
834
|
+
steps.push({
|
|
835
|
+
route: '/todos',
|
|
836
|
+
label: 'crud-todo-create-form',
|
|
837
|
+
section: 'crud',
|
|
838
|
+
waitForSelector: '[data-testid="todo-form"]',
|
|
839
|
+
waitForTimeout: 1500,
|
|
840
|
+
})
|
|
841
|
+
steps.push({
|
|
842
|
+
route: '/todos',
|
|
843
|
+
label: 'crud-todo-after-create',
|
|
844
|
+
section: 'crud',
|
|
845
|
+
waitForSelector: '[data-testid="todo-list"]',
|
|
846
|
+
waitForTimeout: 1500,
|
|
847
|
+
})
|
|
848
|
+
steps.push({
|
|
849
|
+
route: '/todos',
|
|
850
|
+
label: 'crud-todo-after-toggle',
|
|
851
|
+
section: 'crud',
|
|
852
|
+
waitForSelector: '[data-testid="todo-list"]',
|
|
853
|
+
waitForTimeout: 1200,
|
|
854
|
+
})
|
|
855
|
+
steps.push({
|
|
856
|
+
route: '/todos',
|
|
857
|
+
label: 'crud-todo-after-delete',
|
|
858
|
+
section: 'crud',
|
|
859
|
+
waitForSelector: '[data-testid="todo-list"]',
|
|
860
|
+
waitForTimeout: 1200,
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (hasChat) {
|
|
865
|
+
steps.push({
|
|
866
|
+
route: '/websocket',
|
|
867
|
+
label: 'crud-chat-after-send',
|
|
868
|
+
section: 'crud',
|
|
869
|
+
waitForSelector: '[data-testid="websocket-container"]',
|
|
870
|
+
waitForTimeout: 2000,
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (hasContent) {
|
|
875
|
+
steps.push({
|
|
876
|
+
route: '/content',
|
|
877
|
+
label: 'crud-content-with-published',
|
|
878
|
+
section: 'crud',
|
|
879
|
+
waitForTimeout: 1500,
|
|
880
|
+
})
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (hasAdmin) {
|
|
884
|
+
steps.push({
|
|
885
|
+
route: '/admin/users',
|
|
886
|
+
label: 'crud-admin-user-create-modal',
|
|
887
|
+
section: 'crud',
|
|
888
|
+
waitForSelector: 'table',
|
|
889
|
+
waitForTimeout: 2000,
|
|
890
|
+
})
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (hasOrders) {
|
|
894
|
+
steps.push({
|
|
895
|
+
route: '/admin/orders',
|
|
896
|
+
label: 'crud-admin-order-detail-modal',
|
|
897
|
+
section: 'crud',
|
|
898
|
+
waitForSelector: 'table',
|
|
899
|
+
waitForTimeout: 2000,
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (hasTickets) {
|
|
904
|
+
steps.push({
|
|
905
|
+
route: '/admin/tickets',
|
|
906
|
+
label: 'crud-admin-ticket-detail-modal',
|
|
907
|
+
section: 'crud',
|
|
908
|
+
waitForSelector: 'table',
|
|
909
|
+
waitForTimeout: 2000,
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return steps
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const PRESET_PAGE_CONFIGS: PresetConfig[] = [
|
|
917
|
+
{
|
|
918
|
+
id: 'fullstack-admin',
|
|
919
|
+
name: 'Full Admin (Recommended)',
|
|
920
|
+
steps: buildStepsForPreset('fullstack-admin'),
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
id: 'xbrowser-marketplace',
|
|
924
|
+
name: 'Plugin Marketplace',
|
|
925
|
+
steps: buildStepsForPreset('xbrowser-marketplace'),
|
|
926
|
+
},
|
|
927
|
+
{ id: 'ecommerce', name: 'E-Commerce', steps: buildStepsForPreset('ecommerce') },
|
|
928
|
+
{ id: 'todo-app', name: 'Todo App', steps: buildStepsForPreset('todo-app') },
|
|
929
|
+
{ id: 'minimal', name: 'Minimal', steps: buildStepsForPreset('minimal') },
|
|
930
|
+
]
|
|
931
|
+
|
|
932
|
+
// ─── Dev Server Helpers ─────────────────────────────────────────────
|
|
933
|
+
|
|
934
|
+
const DEV_SERVER_PORT = 30999
|
|
935
|
+
|
|
936
|
+
async function scaffoldProject(presetId: string, outputDir: string): Promise<string> {
|
|
937
|
+
console.log(`\n 🏗️ Scaffolding preset "${presetId}" → ${outputDir}`)
|
|
938
|
+
const cliEntry = path.join(TEMPLATE_ROOT, '../src/index.ts')
|
|
939
|
+
execSync(`npx tsx "${cliEntry}" "test-${presetId}" -p ${presetId} -o "${outputDir}"`, {
|
|
940
|
+
cwd: TEMPLATE_ROOT,
|
|
941
|
+
stdio: 'pipe',
|
|
942
|
+
timeout: 120_000,
|
|
943
|
+
env: { ...process.env, CI: 'true' },
|
|
944
|
+
})
|
|
945
|
+
console.log(` ✅ Scaffolded to ${outputDir}`)
|
|
946
|
+
return outputDir
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function installDeps(projectPath: string): Promise<void> {
|
|
950
|
+
console.log(` 📦 Installing dependencies in ${projectPath}...`)
|
|
951
|
+
execSync('npm install --prefer-offline', { cwd: projectPath, stdio: 'pipe', timeout: 180_000 })
|
|
952
|
+
console.log(' ✅ Dependencies installed')
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
interface DevServerHandle {
|
|
956
|
+
port: number
|
|
957
|
+
process: ReturnType<typeof spawn>
|
|
958
|
+
url: string
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function startDevServer(projectPath: string, port: number): Promise<DevServerHandle> {
|
|
962
|
+
console.log(` 🚀 Starting dev server on port ${port}...`)
|
|
963
|
+
const proc = spawn('npx', ['vite', '--port', String(port), '--host'], {
|
|
964
|
+
cwd: projectPath,
|
|
965
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
966
|
+
env: { ...process.env, NODE_ENV: 'development' },
|
|
967
|
+
detached: false,
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
let stdout = ''
|
|
971
|
+
let stderr = ''
|
|
972
|
+
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
973
|
+
stdout += chunk.toString()
|
|
974
|
+
})
|
|
975
|
+
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
976
|
+
stderr += chunk.toString()
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
const url = `http://localhost:${port}`
|
|
980
|
+
for (let i = 0; i < 60; i++) {
|
|
981
|
+
await new Promise(r => setTimeout(r, 1000))
|
|
982
|
+
try {
|
|
983
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) })
|
|
984
|
+
if (res.ok) {
|
|
985
|
+
console.log(` ✅ Dev server ready at ${url}`)
|
|
986
|
+
return { port, process: proc, url }
|
|
987
|
+
}
|
|
988
|
+
} catch {
|
|
989
|
+
// keep waiting
|
|
990
|
+
}
|
|
991
|
+
if (!proc.pid || proc.exitCode !== null) {
|
|
992
|
+
throw new Error(`Dev server exited unexpectedly:\nstdout: ${stdout}\nstderr: ${stderr}`)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
throw new Error(`Dev server did not start within 60s:\nstdout: ${stdout}\nstderr: ${stderr}`)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function stopDevServer(handle: DevServerHandle): void {
|
|
999
|
+
try {
|
|
1000
|
+
handle.process.kill('SIGTERM')
|
|
1001
|
+
} catch {
|
|
1002
|
+
/* already dead */
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ─── Gallery Generator ─────────────────────────────────────────────
|
|
1007
|
+
|
|
1008
|
+
interface GalleryImage {
|
|
1009
|
+
presetId: string
|
|
1010
|
+
presetName: string
|
|
1011
|
+
filename: string
|
|
1012
|
+
label: string
|
|
1013
|
+
relativePath: string
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function generateGallery(screenshotsBaseDir: string, presets: PresetConfig[]): void {
|
|
1017
|
+
const images: GalleryImage[] = []
|
|
1018
|
+
|
|
1019
|
+
for (const preset of presets) {
|
|
1020
|
+
const presetDir = path.join(screenshotsBaseDir, preset.id)
|
|
1021
|
+
if (!fs.existsSync(presetDir)) continue
|
|
1022
|
+
const files = fs
|
|
1023
|
+
.readdirSync(presetDir)
|
|
1024
|
+
.filter(f => f.endsWith('.png'))
|
|
1025
|
+
.sort()
|
|
1026
|
+
for (const file of files) {
|
|
1027
|
+
images.push({
|
|
1028
|
+
presetId: preset.id,
|
|
1029
|
+
presetName: preset.name,
|
|
1030
|
+
filename: file,
|
|
1031
|
+
label: file.replace(/^\d+-/, '').replace(/\.png$/, ''),
|
|
1032
|
+
relativePath: path.join(preset.id, file),
|
|
1033
|
+
})
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const html = `<!DOCTYPE html>
|
|
1038
|
+
<html lang="en">
|
|
1039
|
+
<head>
|
|
1040
|
+
<meta charset="UTF-8">
|
|
1041
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1042
|
+
<title>Screenshot Gallery — Visual Reference</title>
|
|
1043
|
+
<style>
|
|
1044
|
+
:root {
|
|
1045
|
+
--bg: #0f1117;
|
|
1046
|
+
--surface: #1a1d27;
|
|
1047
|
+
--surface-hover: #242836;
|
|
1048
|
+
--border: #2e3347;
|
|
1049
|
+
--text: #e4e6ef;
|
|
1050
|
+
--text-muted: #8b8fa3;
|
|
1051
|
+
--accent: #6c8cff;
|
|
1052
|
+
--accent-glow: rgba(108,140,255,.15);
|
|
1053
|
+
--success: #4ade80;
|
|
1054
|
+
--radius: 12px;
|
|
1055
|
+
}
|
|
1056
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
1057
|
+
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); line-height:1.6; }
|
|
1058
|
+
.header { text-align:center; padding:48px 24px 32px; background:linear-gradient(135deg,#1a1d27 0%,#161922 100%); border-bottom:1px solid var(--border); }
|
|
1059
|
+
.header h1 { font-size:28px; font-weight:700; letter-spacing:-.5px; margin-bottom:8px; }
|
|
1060
|
+
.header p { color:var(--text-muted); font-size:14px; }
|
|
1061
|
+
.header .badge { display:inline-block; padding:4px 12px; border-radius:20px; background:var(--accent-glow); color:var(--accent); font-size:12px; font-weight:600; margin-top:12px; }
|
|
1062
|
+
.stats { display:flex; justify-content:center; gap:32px; margin-top:20px; }
|
|
1063
|
+
.stat { text-align:center; }
|
|
1064
|
+
.stat .num { font-size:24px; font-weight:700; color:var(--accent); }
|
|
1065
|
+
.stat .lbl { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.5px; }
|
|
1066
|
+
.container { max-width:1400px; margin:0 auto; padding:32px 24px; }
|
|
1067
|
+
.section { margin-bottom:56px; }
|
|
1068
|
+
.section-title { display:flex; align-items:center; gap:10px; font-size:18px; font-weight:600; margin-bottom:20px; padding-bottom:12px; border-bottom:1px solid var(--border); }
|
|
1069
|
+
.section-title .dot { width:8px; height:8px; border-radius:50%; background:var(--success); box-shadow:0 0 8px var(--success); }
|
|
1070
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:20px; }
|
|
1071
|
+
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; transition:transform .2s ease, box-shadow .2s ease, border-color .2s ease; cursor:pointer; }
|
|
1072
|
+
.card:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(0,0,0,.35); border-color:var(--accent); }
|
|
1073
|
+
.card img { width:100%; height:auto; display:block; border-bottom:1px solid var(--border); }
|
|
1074
|
+
.card-label { padding:10px 14px; font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
1075
|
+
.card-label code { background:var(--bg); padding:2px 6px; border-radius:4px; font-size:11px; color:var(--accent); margin-left:6px; }
|
|
1076
|
+
.card .section-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.5px; margin-right:6px; }
|
|
1077
|
+
.card .section-badge.cli { background:#f9e2af22; color:#f9e2af; }
|
|
1078
|
+
.card .section-badge.client { background:#89b4fa22; color:#89b4fa; }
|
|
1079
|
+
.card .section-badge.admin { background:#a6e3a122; color:#a6e3a1; }
|
|
1080
|
+
.card .section-badge.crud { background:#cba6f722; color:#cba6f7; }
|
|
1081
|
+
.lightbox { position:fixed; inset:0; background:rgba(0,0,0,.85); display:none; align-items:center; justify-content:center; z-index:9999; cursor:pointer; backdrop-filter:blur(8px); }
|
|
1082
|
+
.lightbox.active { display:flex; }
|
|
1083
|
+
.lightbox img { max-width:90vw; max-height:90vh; border-radius:var(--radius); box-shadow:0 24px 64px rgba(0,0,0,.5); }
|
|
1084
|
+
.footer { text-align:center; padding:32px; color:var(--text-muted); font-size:12px; border-top:1px solid var(--border); }
|
|
1085
|
+
</style>
|
|
1086
|
+
</head>
|
|
1087
|
+
<body>
|
|
1088
|
+
|
|
1089
|
+
<div class="header">
|
|
1090
|
+
<h1>🖼️ Screenshot Gallery</h1>
|
|
1091
|
+
<p>Visual reference for all template presets — generated automatically by Playwright E2E tests</p>
|
|
1092
|
+
<div class="badge">@slow · Per-Preset Screenshot Gallery</div>
|
|
1093
|
+
<div class="stats">
|
|
1094
|
+
<div class="stat"><div class="num">${presets.length}</div><div class="lbl">Presets</div></div>
|
|
1095
|
+
<div class="stat"><div class="num">${
|
|
1096
|
+
images.length
|
|
1097
|
+
}</div><div class="lbl">Screenshots</div></div>
|
|
1098
|
+
</div>
|
|
1099
|
+
</div>
|
|
1100
|
+
|
|
1101
|
+
<div class="container">
|
|
1102
|
+
|
|
1103
|
+
${presets
|
|
1104
|
+
.map(preset => {
|
|
1105
|
+
const presetImages = images.filter(img => img.presetId === preset.id)
|
|
1106
|
+
if (presetImages.length === 0) return ''
|
|
1107
|
+
return `
|
|
1108
|
+
<div class="section" id="${preset.id}">
|
|
1109
|
+
<div class="section-title"><span class="dot"></span>${
|
|
1110
|
+
preset.name
|
|
1111
|
+
}<code style="color:var(--text-muted);font-size:13px;margin-left:8px;">${preset.id}</code></div>
|
|
1112
|
+
<div class="grid">
|
|
1113
|
+
${presetImages
|
|
1114
|
+
.map(img => {
|
|
1115
|
+
const badge = img.label.startsWith('cli-')
|
|
1116
|
+
? 'cli'
|
|
1117
|
+
: img.label.startsWith('admin-')
|
|
1118
|
+
? 'admin'
|
|
1119
|
+
: img.label.startsWith('crud-')
|
|
1120
|
+
? 'crud'
|
|
1121
|
+
: 'client'
|
|
1122
|
+
return `
|
|
1123
|
+
<div class="card" onclick="showLightbox('${img.relativePath}')">
|
|
1124
|
+
<img src="${img.relativePath}" alt="${img.label}" loading="lazy" />
|
|
1125
|
+
<div class="card-label"><span class="section-badge ${badge}">${badge}</span>${img.label.replace(
|
|
1126
|
+
/-/g,
|
|
1127
|
+
' '
|
|
1128
|
+
)}</div>
|
|
1129
|
+
</div>`
|
|
1130
|
+
})
|
|
1131
|
+
.join('\n')}
|
|
1132
|
+
</div>
|
|
1133
|
+
</div>`
|
|
1134
|
+
})
|
|
1135
|
+
.join('\n')}
|
|
1136
|
+
|
|
1137
|
+
</div>
|
|
1138
|
+
|
|
1139
|
+
<div class="lightbox" id="lightbox" onclick="hideLightbox()">
|
|
1140
|
+
<img id="lb-img" src="" alt="preview" />
|
|
1141
|
+
</div>
|
|
1142
|
+
|
|
1143
|
+
<div class="footer">
|
|
1144
|
+
Generated by <code>visual-screenshots.spec.ts</code> · ${
|
|
1145
|
+
new Date().toISOString().split('T')[0]
|
|
1146
|
+
}
|
|
1147
|
+
</div>
|
|
1148
|
+
|
|
1149
|
+
<script>
|
|
1150
|
+
function showLightbox(src) { document.getElementById('lb-img').src = src; document.getElementById('lightbox').classList.add('active'); }
|
|
1151
|
+
function hideLightbox() { document.getElementById('lightbox').classList.remove('active'); }
|
|
1152
|
+
document.addEventListener('keydown', e => { if(e.key==='Escape') hideLightbox(); });
|
|
1153
|
+
</script>
|
|
1154
|
+
</body></html>`
|
|
1155
|
+
|
|
1156
|
+
const indexPath = path.join(screenshotsBaseDir, 'index.html')
|
|
1157
|
+
fs.writeFileSync(indexPath, html, 'utf-8')
|
|
1158
|
+
console.log(`\n 🖼️ Gallery generated: ${indexPath}`)
|
|
1159
|
+
console.log(` ${images.length} screenshots across ${presets.length} presets\n`)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1163
|
+
// PART 1: Template Self-Screenshots (existing functionality)
|
|
1164
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1165
|
+
|
|
1166
|
+
test.describe('Visual Screenshots — Template', () => {
|
|
1167
|
+
test.describe.configure({ retries: 1 })
|
|
1168
|
+
test.setTimeout(60000)
|
|
1169
|
+
|
|
1170
|
+
test.beforeEach(async ({ page }) => {
|
|
1171
|
+
checkConsoleErrors(page)
|
|
1172
|
+
try {
|
|
1173
|
+
await page.request.post(`${getBaseUrl()}/api/__test__/cleanup`)
|
|
1174
|
+
} catch {
|
|
1175
|
+
// ignore
|
|
1176
|
+
}
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
test('todo page — empty state', async ({ page }) => {
|
|
1180
|
+
await page.goto(`${getBaseUrl()}/todos`)
|
|
1181
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1182
|
+
await page.waitForTimeout(500)
|
|
1183
|
+
await expect(page.locator('body')).toBeVisible()
|
|
1184
|
+
await capturePage(page, '01-todo-page')
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
test('notifications page', async ({ page }) => {
|
|
1188
|
+
await page.goto(`${getBaseUrl()}/notifications`)
|
|
1189
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1190
|
+
await page.waitForTimeout(500)
|
|
1191
|
+
await capturePage(page, '03-notifications-page')
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
test('websocket page', async ({ page }) => {
|
|
1195
|
+
await page.goto(`${getBaseUrl()}/websocket`)
|
|
1196
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1197
|
+
await page.waitForTimeout(500)
|
|
1198
|
+
await capturePage(page, '04-websocket-page')
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1201
|
+
test('content page', async ({ page }) => {
|
|
1202
|
+
await page.goto(`${getBaseUrl()}/content`)
|
|
1203
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1204
|
+
await page.waitForTimeout(500)
|
|
1205
|
+
await capturePage(page, '05-content-page')
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
test('login page', async ({ page }) => {
|
|
1209
|
+
await page.goto(`${getBaseUrl()}/login`)
|
|
1210
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1211
|
+
await page.waitForTimeout(500)
|
|
1212
|
+
await capturePage(page, '06-login-page')
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
test('register page', async ({ page }) => {
|
|
1216
|
+
await page.goto(`${getBaseUrl()}/register`)
|
|
1217
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1218
|
+
await page.waitForTimeout(500)
|
|
1219
|
+
await capturePage(page, '07-register-page')
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
test('admin login page', async ({ page }) => {
|
|
1223
|
+
await page.goto(`${getBaseUrl()}/admin/login`)
|
|
1224
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1225
|
+
await page.waitForTimeout(500)
|
|
1226
|
+
await capturePage(page, '08-admin-login-page')
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
test('admin dashboard (if auth works)', async ({ page }) => {
|
|
1230
|
+
await page.request.post(`${getBaseUrl()}/api/auth/register`, {
|
|
1231
|
+
data: { email: 'admin@test.com', password: 'admin123', username: 'admin' },
|
|
1232
|
+
})
|
|
1233
|
+
const loginRes = await page.request.post(`${getBaseUrl()}/api/auth/login`, {
|
|
1234
|
+
data: { username: 'admin', password: 'admin123' },
|
|
1235
|
+
})
|
|
1236
|
+
const loginBody = await loginRes.json()
|
|
1237
|
+
const token = loginBody.success ? loginBody.data?.token : null
|
|
1238
|
+
|
|
1239
|
+
if (token) {
|
|
1240
|
+
await page.goto(`${getBaseUrl()}/admin/login`)
|
|
1241
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1242
|
+
await page.evaluate((t: string) => {
|
|
1243
|
+
const storage = {
|
|
1244
|
+
state: {
|
|
1245
|
+
user: { id: '1', email: 'admin@test.com', name: 'Admin', role: 'super_admin' },
|
|
1246
|
+
token: t,
|
|
1247
|
+
isAuthenticated: true,
|
|
1248
|
+
},
|
|
1249
|
+
version: 0,
|
|
1250
|
+
}
|
|
1251
|
+
localStorage.setItem('admin-storage', JSON.stringify(storage))
|
|
1252
|
+
}, token)
|
|
1253
|
+
await page.goto(`${getBaseUrl()}/admin/dashboard`)
|
|
1254
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1255
|
+
await page.waitForTimeout(1500)
|
|
1256
|
+
await capturePage(page, '09-admin-dashboard')
|
|
1257
|
+
} else {
|
|
1258
|
+
await page.goto(`${getBaseUrl()}/admin/login`)
|
|
1259
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1260
|
+
await page.waitForTimeout(500)
|
|
1261
|
+
await capturePage(page, '09-admin-login-fallback')
|
|
1262
|
+
}
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
test('todo page — mobile (375×812)', async ({ page }) => {
|
|
1266
|
+
await page.setViewportSize({ width: 375, height: 812 })
|
|
1267
|
+
await page.goto(`${getBaseUrl()}/todos`)
|
|
1268
|
+
await page.waitForLoadState('domcontentloaded')
|
|
1269
|
+
await page.waitForTimeout(500)
|
|
1270
|
+
await capturePage(page, '10-todo-page-mobile')
|
|
1271
|
+
})
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1275
|
+
// PART 2: Per-Preset Screenshot Gallery
|
|
1276
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1277
|
+
|
|
1278
|
+
test.describe('Per-Preset Screenshot Gallery @slow', () => {
|
|
1279
|
+
test.describe.configure({ retries: 1, mode: 'serial' })
|
|
1280
|
+
test.setTimeout(600_000)
|
|
1281
|
+
|
|
1282
|
+
test.skip(({ browserName }) => browserName !== 'chromium', 'Gallery only runs on Chromium')
|
|
1283
|
+
|
|
1284
|
+
for (const preset of PRESET_PAGE_CONFIGS) {
|
|
1285
|
+
test.describe(`Preset: ${preset.name} (${preset.id})`, () => {
|
|
1286
|
+
let serverHandle: DevServerHandle | null = null
|
|
1287
|
+
let tokens: AuthTokens | null = null
|
|
1288
|
+
const presetOutputDir = path.join(GALLERY_DIR, preset.id)
|
|
1289
|
+
const presetProjectPath = path.join(
|
|
1290
|
+
os.tmpdir(),
|
|
1291
|
+
`biomimic-gallery-${preset.id}-${Date.now()}`
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
test.beforeAll(async () => {
|
|
1295
|
+
test.setTimeout(300_000)
|
|
1296
|
+
fs.mkdirSync(presetOutputDir, { recursive: true })
|
|
1297
|
+
|
|
1298
|
+
const projectPath = await scaffoldProject(preset.id, presetProjectPath)
|
|
1299
|
+
await installDeps(projectPath)
|
|
1300
|
+
serverHandle = await startDevServer(
|
|
1301
|
+
projectPath,
|
|
1302
|
+
DEV_SERVER_PORT + PRESET_PAGE_CONFIGS.indexOf(preset)
|
|
1303
|
+
)
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
test.afterAll(async () => {
|
|
1307
|
+
if (serverHandle) {
|
|
1308
|
+
stopDevServer(serverHandle)
|
|
1309
|
+
serverHandle = null
|
|
1310
|
+
}
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
test('00 — CLI creation screenshot', async ({ page }) => {
|
|
1314
|
+
if (!serverHandle) throw new Error('Dev server not started')
|
|
1315
|
+
checkConsoleErrors(page)
|
|
1316
|
+
|
|
1317
|
+
const cliEntry = path.join(TEMPLATE_ROOT, '../src/index.ts')
|
|
1318
|
+
const ok = await captureTerminalScreenshot(
|
|
1319
|
+
'npx',
|
|
1320
|
+
[`tsx "${cliEntry}" "test-${preset.id}" --preset ${preset.id} -o "${presetProjectPath}"`],
|
|
1321
|
+
TEMPLATE_ROOT,
|
|
1322
|
+
presetOutputDir,
|
|
1323
|
+
'00-cli-create',
|
|
1324
|
+
page
|
|
1325
|
+
)
|
|
1326
|
+
expect(ok).toBeTruthy()
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
test('01 — client public pages', async ({ page }) => {
|
|
1330
|
+
if (!serverHandle) throw new Error('Dev server not started')
|
|
1331
|
+
checkConsoleErrors(page)
|
|
1332
|
+
|
|
1333
|
+
const publicSteps = preset.steps.filter(s => s.section === 'client-public')
|
|
1334
|
+
if (publicSteps.length === 0) {
|
|
1335
|
+
expect(true).toBeTruthy()
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
let idx = 1
|
|
1340
|
+
for (const step of publicSteps) {
|
|
1341
|
+
await screenshotPage(
|
|
1342
|
+
page,
|
|
1343
|
+
serverHandle.url,
|
|
1344
|
+
step.route,
|
|
1345
|
+
step.label,
|
|
1346
|
+
presetOutputDir,
|
|
1347
|
+
idx,
|
|
1348
|
+
{
|
|
1349
|
+
waitForSelector: step.waitForSelector,
|
|
1350
|
+
waitForTimeout: step.waitForTimeout,
|
|
1351
|
+
}
|
|
1352
|
+
)
|
|
1353
|
+
idx++
|
|
1354
|
+
}
|
|
1355
|
+
expect(true).toBeTruthy()
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
test('02 — seed data + client authenticated pages', async ({ page }) => {
|
|
1359
|
+
if (!serverHandle) throw new Error('Dev server not started')
|
|
1360
|
+
checkConsoleErrors(page)
|
|
1361
|
+
|
|
1362
|
+
const authSteps = preset.steps.filter(s => s.section === 'client-auth')
|
|
1363
|
+
const adminSteps = preset.steps.filter(
|
|
1364
|
+
s => s.section === 'admin-public' || s.section === 'admin-auth'
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
// Cleanup
|
|
1368
|
+
await page.request.post(`${serverHandle.url}/api/__test__/cleanup`).catch(() => {})
|
|
1369
|
+
|
|
1370
|
+
// Register + login users (works for both auth and no-auth presets)
|
|
1371
|
+
if (authSteps.length > 0 || adminSteps.length > 0) {
|
|
1372
|
+
tokens = await registerAndLoginUsers(page, serverHandle.url, preset.id)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (authSteps.length === 0) {
|
|
1376
|
+
expect(true).toBeTruthy()
|
|
1377
|
+
return
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Seed data
|
|
1381
|
+
await seedAllData(page, serverHandle.url, tokens, preset.id)
|
|
1382
|
+
|
|
1383
|
+
// Set client auth in localStorage
|
|
1384
|
+
await page.goto(serverHandle.url, { timeout: 30_000 })
|
|
1385
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1386
|
+
await setClientAuth(page, tokens.clientToken, tokens.clientUser)
|
|
1387
|
+
|
|
1388
|
+
// Take authenticated screenshots
|
|
1389
|
+
const clientPublicCount = preset.steps.filter(s => s.section === 'client-public').length
|
|
1390
|
+
let idx = clientPublicCount + 1
|
|
1391
|
+
for (const step of authSteps) {
|
|
1392
|
+
await page.goto(serverHandle.url)
|
|
1393
|
+
await setClientAuth(page, tokens.clientToken, tokens.clientUser)
|
|
1394
|
+
await screenshotPage(
|
|
1395
|
+
page,
|
|
1396
|
+
serverHandle.url,
|
|
1397
|
+
step.route,
|
|
1398
|
+
step.label,
|
|
1399
|
+
presetOutputDir,
|
|
1400
|
+
idx,
|
|
1401
|
+
{
|
|
1402
|
+
waitForSelector: step.waitForSelector,
|
|
1403
|
+
waitForTimeout: step.waitForTimeout,
|
|
1404
|
+
}
|
|
1405
|
+
)
|
|
1406
|
+
idx++
|
|
1407
|
+
}
|
|
1408
|
+
expect(true).toBeTruthy()
|
|
1409
|
+
})
|
|
1410
|
+
|
|
1411
|
+
test('03 — admin pages', async ({ page }) => {
|
|
1412
|
+
if (!serverHandle) throw new Error('Dev server not started')
|
|
1413
|
+
if (!tokens) throw new Error('Auth tokens not available')
|
|
1414
|
+
checkConsoleErrors(page)
|
|
1415
|
+
|
|
1416
|
+
const adminPublicSteps = preset.steps.filter(s => s.section === 'admin-public')
|
|
1417
|
+
const adminAuthSteps = preset.steps.filter(s => s.section === 'admin-auth')
|
|
1418
|
+
if (adminPublicSteps.length === 0 && adminAuthSteps.length === 0) {
|
|
1419
|
+
expect(true).toBeTruthy()
|
|
1420
|
+
return
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const clientPublicCount = preset.steps.filter(s => s.section === 'client-public').length
|
|
1424
|
+
const clientAuthCount = preset.steps.filter(s => s.section === 'client-auth').length
|
|
1425
|
+
let idx = clientPublicCount + clientAuthCount + 1
|
|
1426
|
+
|
|
1427
|
+
// Screenshot admin login page (public)
|
|
1428
|
+
for (const step of adminPublicSteps) {
|
|
1429
|
+
await screenshotPage(
|
|
1430
|
+
page,
|
|
1431
|
+
serverHandle.url,
|
|
1432
|
+
step.route,
|
|
1433
|
+
step.label,
|
|
1434
|
+
presetOutputDir,
|
|
1435
|
+
idx,
|
|
1436
|
+
{
|
|
1437
|
+
waitForSelector: step.waitForSelector,
|
|
1438
|
+
waitForTimeout: step.waitForTimeout,
|
|
1439
|
+
}
|
|
1440
|
+
)
|
|
1441
|
+
idx++
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Login as admin + take authenticated screenshots
|
|
1445
|
+
if (adminAuthSteps.length > 0 && tokens) {
|
|
1446
|
+
// Navigate to admin and set auth
|
|
1447
|
+
await page.goto(`${serverHandle.url}/admin/login`, { timeout: 30_000 })
|
|
1448
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1449
|
+
await setAdminAuth(page, tokens.adminToken, tokens.adminUser)
|
|
1450
|
+
|
|
1451
|
+
for (const step of adminAuthSteps) {
|
|
1452
|
+
// Re-set admin auth before each page (in case navigation clears it)
|
|
1453
|
+
await page.goto(`${serverHandle.url}/admin/login`, { timeout: 30_000 })
|
|
1454
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1455
|
+
await setAdminAuth(page, tokens.adminToken, tokens.adminUser)
|
|
1456
|
+
|
|
1457
|
+
await screenshotPage(
|
|
1458
|
+
page,
|
|
1459
|
+
serverHandle.url,
|
|
1460
|
+
step.route,
|
|
1461
|
+
step.label,
|
|
1462
|
+
presetOutputDir,
|
|
1463
|
+
idx,
|
|
1464
|
+
{
|
|
1465
|
+
waitForSelector: step.waitForSelector,
|
|
1466
|
+
waitForTimeout: step.waitForTimeout,
|
|
1467
|
+
}
|
|
1468
|
+
)
|
|
1469
|
+
idx++
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
expect(true).toBeTruthy()
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
test('04 — CRUD operations', async ({ page }) => {
|
|
1476
|
+
test.setTimeout(180_000)
|
|
1477
|
+
if (!serverHandle) throw new Error('Dev server not started')
|
|
1478
|
+
if (!tokens) throw new Error('Auth tokens not available')
|
|
1479
|
+
checkConsoleErrors(page)
|
|
1480
|
+
|
|
1481
|
+
const crudSteps = preset.steps.filter(s => s.section === 'crud')
|
|
1482
|
+
const presetConfig = getPreset(preset.id)
|
|
1483
|
+
const modules = new Set(presetConfig?.modules ?? [])
|
|
1484
|
+
|
|
1485
|
+
if (crudSteps.length === 0) {
|
|
1486
|
+
expect(true).toBeTruthy()
|
|
1487
|
+
return
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const nonCrudCount = preset.steps.filter(s => s.section !== 'crud').length
|
|
1491
|
+
let idx = nonCrudCount + 1
|
|
1492
|
+
|
|
1493
|
+
const baseUrl = serverHandle.url
|
|
1494
|
+
|
|
1495
|
+
// --- Todo CRUD ---
|
|
1496
|
+
if (modules.has('todos')) {
|
|
1497
|
+
// 1. Navigate to todos page, set auth, then reload so app reads auth from localStorage
|
|
1498
|
+
await page.goto(`${baseUrl}/todos`, { timeout: 30_000 })
|
|
1499
|
+
await page.waitForLoadState('networkidle', { timeout: 60_000 }).catch(() => {})
|
|
1500
|
+
await setClientAuth(page, tokens.clientToken, tokens.clientUser)
|
|
1501
|
+
await page.reload({ timeout: 30_000 })
|
|
1502
|
+
await page.waitForLoadState('networkidle', { timeout: 60_000 }).catch(() => {})
|
|
1503
|
+
await page
|
|
1504
|
+
.waitForSelector('[data-testid="todo-form"], form, input[type="text"]', {
|
|
1505
|
+
timeout: 30_000,
|
|
1506
|
+
})
|
|
1507
|
+
.catch(() => {})
|
|
1508
|
+
await page.waitForTimeout(800)
|
|
1509
|
+
const formIdx = idx++
|
|
1510
|
+
await capturePage(
|
|
1511
|
+
page,
|
|
1512
|
+
`${String(formIdx).padStart(2, '0')}-crud-todo-create-form`,
|
|
1513
|
+
presetOutputDir
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
// 2. Fill and submit a new todo
|
|
1517
|
+
const titleInput = page.locator('[data-testid="todo-title-input"]')
|
|
1518
|
+
const descInput = page.locator('[data-testid="todo-description-input"]')
|
|
1519
|
+
const addBtn = page.locator('[data-testid="add-todo-button"]')
|
|
1520
|
+
if (await titleInput.isVisible().catch(() => false)) {
|
|
1521
|
+
await titleInput.fill('Screenshot CRUD test todo')
|
|
1522
|
+
if (await descInput.isVisible().catch(() => false)) {
|
|
1523
|
+
await descInput.fill('Created by E2E visual screenshot test')
|
|
1524
|
+
}
|
|
1525
|
+
if (await addBtn.isVisible().catch(() => false)) {
|
|
1526
|
+
await addBtn.click()
|
|
1527
|
+
await page.waitForTimeout(1200)
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
const afterCreateIdx = idx++
|
|
1531
|
+
await capturePage(
|
|
1532
|
+
page,
|
|
1533
|
+
`${String(afterCreateIdx).padStart(2, '0')}-crud-todo-after-create`,
|
|
1534
|
+
presetOutputDir
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
// 3. Toggle the new todo's completion status
|
|
1538
|
+
const todoItems = page.locator('[data-testid="todo-item"]')
|
|
1539
|
+
const todoCount = await todoItems.count()
|
|
1540
|
+
if (todoCount > 0) {
|
|
1541
|
+
// Click the status toggle on the last item (the one we just created)
|
|
1542
|
+
const lastItem = todoItems.nth(todoCount - 1)
|
|
1543
|
+
const statusBtn = lastItem.locator('[data-testid="todo-status"]')
|
|
1544
|
+
if (await statusBtn.isVisible()) {
|
|
1545
|
+
await statusBtn.click()
|
|
1546
|
+
await page.waitForTimeout(800)
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
const afterToggleIdx = idx++
|
|
1550
|
+
await capturePage(
|
|
1551
|
+
page,
|
|
1552
|
+
`${String(afterToggleIdx).padStart(2, '0')}-crud-todo-after-toggle`,
|
|
1553
|
+
presetOutputDir
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
// 4. Delete the todo we just created
|
|
1557
|
+
if (todoCount > 0) {
|
|
1558
|
+
const lastItem = todoItems.nth(todoCount - 1)
|
|
1559
|
+
const deleteBtn = lastItem.locator('[data-testid="delete-button"]')
|
|
1560
|
+
if (await deleteBtn.isVisible()) {
|
|
1561
|
+
await deleteBtn.click()
|
|
1562
|
+
await page.waitForTimeout(800)
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
const afterDeleteIdx = idx++
|
|
1566
|
+
await capturePage(
|
|
1567
|
+
page,
|
|
1568
|
+
`${String(afterDeleteIdx).padStart(2, '0')}-crud-todo-after-delete`,
|
|
1569
|
+
presetOutputDir
|
|
1570
|
+
)
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// --- Chat / WebSocket CRUD ---
|
|
1574
|
+
if (modules.has('chat')) {
|
|
1575
|
+
await page.goto(`${baseUrl}/websocket`, { timeout: 30_000 })
|
|
1576
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1577
|
+
await setClientAuth(page, tokens.clientToken, tokens.clientUser)
|
|
1578
|
+
await page.reload({ timeout: 30_000 })
|
|
1579
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1580
|
+
await page
|
|
1581
|
+
.waitForSelector('[data-testid="websocket-container"]', { timeout: 15_000 })
|
|
1582
|
+
.catch(() => {})
|
|
1583
|
+
|
|
1584
|
+
// Connect
|
|
1585
|
+
const connectBtn = page.locator('[data-testid="connect-ws-button"]')
|
|
1586
|
+
if (await connectBtn.isVisible()) {
|
|
1587
|
+
await connectBtn.click()
|
|
1588
|
+
await page.waitForTimeout(1500)
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// Send a message
|
|
1592
|
+
const msgInput = page.locator('[data-testid="ws-message-input"]')
|
|
1593
|
+
const sendBtn = page.locator('[data-testid="send-message-button"]')
|
|
1594
|
+
if (await msgInput.isVisible()) {
|
|
1595
|
+
await msgInput.fill('Hello from screenshot test!')
|
|
1596
|
+
if (await sendBtn.isVisible()) {
|
|
1597
|
+
await sendBtn.click()
|
|
1598
|
+
await page.waitForTimeout(1000)
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
const afterChatIdx = idx++
|
|
1602
|
+
await capturePage(
|
|
1603
|
+
page,
|
|
1604
|
+
`${String(afterChatIdx).padStart(2, '0')}-crud-chat-after-send`,
|
|
1605
|
+
presetOutputDir
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
// Disconnect
|
|
1609
|
+
const disconnectBtn = page.locator('[data-testid="disconnect-ws-button"]')
|
|
1610
|
+
if (await disconnectBtn.isVisible()) {
|
|
1611
|
+
await disconnectBtn.click()
|
|
1612
|
+
await page.waitForTimeout(500)
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// --- Content page with published data ---
|
|
1617
|
+
if (modules.has('content')) {
|
|
1618
|
+
await page.goto(`${baseUrl}/content`, { timeout: 30_000 })
|
|
1619
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1620
|
+
await setClientAuth(page, tokens.clientToken, tokens.clientUser)
|
|
1621
|
+
await page.reload({ timeout: 30_000 })
|
|
1622
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1623
|
+
await page.waitForTimeout(1500)
|
|
1624
|
+
const contentIdx = idx++
|
|
1625
|
+
await capturePage(
|
|
1626
|
+
page,
|
|
1627
|
+
`${String(contentIdx).padStart(2, '0')}-crud-content-with-published`,
|
|
1628
|
+
presetOutputDir
|
|
1629
|
+
)
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// --- Admin CRUD: user create modal ---
|
|
1633
|
+
if (modules.has('admin')) {
|
|
1634
|
+
await page.goto(`${baseUrl}/admin/users`, { timeout: 30_000 })
|
|
1635
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1636
|
+
await setAdminAuth(page, tokens.adminToken, tokens.adminUser)
|
|
1637
|
+
await page.reload({ timeout: 30_000 })
|
|
1638
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1639
|
+
await page.waitForSelector('table', { timeout: 15_000 }).catch(() => {})
|
|
1640
|
+
await page.waitForTimeout(1500)
|
|
1641
|
+
|
|
1642
|
+
// Try to open create user modal
|
|
1643
|
+
const createUserBtn = page
|
|
1644
|
+
.getByRole('button', { name: /新建|Create|添加|Add|New/i })
|
|
1645
|
+
.first()
|
|
1646
|
+
if (await createUserBtn.isVisible().catch(() => false)) {
|
|
1647
|
+
await createUserBtn.click()
|
|
1648
|
+
await page.waitForTimeout(1000)
|
|
1649
|
+
}
|
|
1650
|
+
const userCreateIdx = idx++
|
|
1651
|
+
await capturePage(
|
|
1652
|
+
page,
|
|
1653
|
+
`${String(userCreateIdx).padStart(2, '0')}-crud-admin-user-create-modal`,
|
|
1654
|
+
presetOutputDir
|
|
1655
|
+
)
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// --- Admin CRUD: order detail modal ---
|
|
1659
|
+
if (modules.has('order')) {
|
|
1660
|
+
await page.goto(`${baseUrl}/admin/orders`, { timeout: 30_000 })
|
|
1661
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1662
|
+
await setAdminAuth(page, tokens.adminToken, tokens.adminUser)
|
|
1663
|
+
await page.reload({ timeout: 30_000 })
|
|
1664
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1665
|
+
await page.waitForSelector('table', { timeout: 15_000 }).catch(() => {})
|
|
1666
|
+
await page.waitForTimeout(1500)
|
|
1667
|
+
|
|
1668
|
+
// Try to open order detail/view modal
|
|
1669
|
+
const viewBtn = page.getByRole('button', { name: /查看|View|详情|Detail/i }).first()
|
|
1670
|
+
if (await viewBtn.isVisible().catch(() => false)) {
|
|
1671
|
+
await viewBtn.click()
|
|
1672
|
+
await page.waitForTimeout(1000)
|
|
1673
|
+
}
|
|
1674
|
+
const orderDetailIdx = idx++
|
|
1675
|
+
await capturePage(
|
|
1676
|
+
page,
|
|
1677
|
+
`${String(orderDetailIdx).padStart(2, '0')}-crud-admin-order-detail-modal`,
|
|
1678
|
+
presetOutputDir
|
|
1679
|
+
)
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// --- Admin CRUD: ticket detail modal ---
|
|
1683
|
+
if (modules.has('ticket')) {
|
|
1684
|
+
await page.goto(`${baseUrl}/admin/tickets`, { timeout: 30_000 })
|
|
1685
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1686
|
+
await setAdminAuth(page, tokens.adminToken, tokens.adminUser)
|
|
1687
|
+
await page.reload({ timeout: 30_000 })
|
|
1688
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
|
1689
|
+
await page.waitForSelector('table', { timeout: 15_000 }).catch(() => {})
|
|
1690
|
+
await page.waitForTimeout(1500)
|
|
1691
|
+
|
|
1692
|
+
// Try to open ticket detail/view modal
|
|
1693
|
+
const viewBtn = page.getByRole('button', { name: /查看|View|详情|Detail/i }).first()
|
|
1694
|
+
if (await viewBtn.isVisible().catch(() => false)) {
|
|
1695
|
+
await viewBtn.click()
|
|
1696
|
+
await page.waitForTimeout(1000)
|
|
1697
|
+
}
|
|
1698
|
+
const ticketDetailIdx = idx++
|
|
1699
|
+
await capturePage(
|
|
1700
|
+
page,
|
|
1701
|
+
`${String(ticketDetailIdx).padStart(2, '0')}-crud-admin-ticket-detail-modal`,
|
|
1702
|
+
presetOutputDir
|
|
1703
|
+
)
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
expect(true).toBeTruthy()
|
|
1707
|
+
})
|
|
1708
|
+
})
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
test('generate HTML gallery from captured screenshots', async () => {
|
|
1712
|
+
generateGallery(GALLERY_DIR, PRESET_PAGE_CONFIGS)
|
|
1713
|
+
|
|
1714
|
+
const galleryIndexPath = path.join(GALLERY_DIR, 'index.html')
|
|
1715
|
+
expect(fs.existsSync(galleryIndexPath)).toBe(true)
|
|
1716
|
+
|
|
1717
|
+
const htmlContent = fs.readFileSync(galleryIndexPath, 'utf-8')
|
|
1718
|
+
expect(htmlContent).toContain('<!DOCTYPE html>')
|
|
1719
|
+
expect(htmlContent).toContain('Screenshot Gallery')
|
|
1720
|
+
expect(htmlContent).toContain('.grid')
|
|
1721
|
+
expect(htmlContent).toContain('.lightbox')
|
|
1722
|
+
|
|
1723
|
+
for (const preset of PRESET_PAGE_CONFIGS) {
|
|
1724
|
+
const presetDir = path.join(GALLERY_DIR, preset.id)
|
|
1725
|
+
if (fs.existsSync(presetDir)) {
|
|
1726
|
+
const files = fs.readdirSync(presetDir).filter(f => f.endsWith('.png'))
|
|
1727
|
+
expect(files.length).toBeGreaterThan(0)
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
})
|
|
1731
|
+
})
|
|
1732
|
+
|
|
1733
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1734
|
+
// PART 3: CLI Screenshots
|
|
1735
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1736
|
+
|
|
1737
|
+
test.describe('CLI Screenshots @slow', () => {
|
|
1738
|
+
test.describe.configure({ retries: 1, mode: 'serial' })
|
|
1739
|
+
test.setTimeout(120_000)
|
|
1740
|
+
|
|
1741
|
+
test.skip(({ browserName }) => browserName !== 'chromium', 'CLI screenshots only run on Chromium')
|
|
1742
|
+
|
|
1743
|
+
const CLI_DIR = path.join(GALLERY_DIR, 'cli')
|
|
1744
|
+
const PROJECT_ROOT = path.resolve(TEMPLATE_ROOT, '..')
|
|
1745
|
+
const CLI_ENTRY = path.join(PROJECT_ROOT, 'src/cli/index.ts')
|
|
1746
|
+
const SCAFFOLD_ENTRY = path.join(PROJECT_ROOT, 'src/index.ts')
|
|
1747
|
+
|
|
1748
|
+
const CLI_COMMANDS: Array<{
|
|
1749
|
+
label: string
|
|
1750
|
+
args: string[]
|
|
1751
|
+
cwd: string
|
|
1752
|
+
entry: string
|
|
1753
|
+
}> = [
|
|
1754
|
+
{
|
|
1755
|
+
label: '01-scaffold-help',
|
|
1756
|
+
args: ['tsx', `"${SCAFFOLD_ENTRY}"`, '--help'],
|
|
1757
|
+
cwd: PROJECT_ROOT,
|
|
1758
|
+
entry: 'npx',
|
|
1759
|
+
},
|
|
1760
|
+
{
|
|
1761
|
+
label: '02-scaffold-presets',
|
|
1762
|
+
args: ['tsx', `"${SCAFFOLD_ENTRY}"`, 'presets'],
|
|
1763
|
+
cwd: PROJECT_ROOT,
|
|
1764
|
+
entry: 'npx',
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
label: '03-cli-help',
|
|
1768
|
+
args: ['tsx', `"${CLI_ENTRY}"`, '--help'],
|
|
1769
|
+
cwd: PROJECT_ROOT,
|
|
1770
|
+
entry: 'npx',
|
|
1771
|
+
},
|
|
1772
|
+
{
|
|
1773
|
+
label: '04-todo-help',
|
|
1774
|
+
args: ['tsx', `"${CLI_ENTRY}"`, 'todo', '--help'],
|
|
1775
|
+
cwd: PROJECT_ROOT,
|
|
1776
|
+
entry: 'npx',
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
label: '05-notification-help',
|
|
1780
|
+
args: ['tsx', `"${CLI_ENTRY}"`, 'notification', '--help'],
|
|
1781
|
+
cwd: PROJECT_ROOT,
|
|
1782
|
+
entry: 'npx',
|
|
1783
|
+
},
|
|
1784
|
+
{
|
|
1785
|
+
label: '06-config-help',
|
|
1786
|
+
args: ['tsx', `"${CLI_ENTRY}"`, 'config', '--help'],
|
|
1787
|
+
cwd: PROJECT_ROOT,
|
|
1788
|
+
entry: 'npx',
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
label: '07-config-path',
|
|
1792
|
+
args: ['tsx', `"${CLI_ENTRY}"`, 'config', 'path'],
|
|
1793
|
+
cwd: PROJECT_ROOT,
|
|
1794
|
+
entry: 'npx',
|
|
1795
|
+
},
|
|
1796
|
+
{
|
|
1797
|
+
label: '08-config-get',
|
|
1798
|
+
args: ['tsx', `"${CLI_ENTRY}"`, 'config', 'get'],
|
|
1799
|
+
cwd: PROJECT_ROOT,
|
|
1800
|
+
entry: 'npx',
|
|
1801
|
+
},
|
|
1802
|
+
]
|
|
1803
|
+
|
|
1804
|
+
test.beforeAll(() => {
|
|
1805
|
+
fs.mkdirSync(CLI_DIR, { recursive: true })
|
|
1806
|
+
})
|
|
1807
|
+
|
|
1808
|
+
for (const cmd of CLI_COMMANDS) {
|
|
1809
|
+
test(`${cmd.label} — ${cmd.args
|
|
1810
|
+
.filter(a => !a.startsWith('"'))
|
|
1811
|
+
.slice(-3)
|
|
1812
|
+
.join(' ')}`, async ({ page }) => {
|
|
1813
|
+
const ok = await captureTerminalScreenshot(
|
|
1814
|
+
cmd.entry,
|
|
1815
|
+
cmd.args,
|
|
1816
|
+
cmd.cwd,
|
|
1817
|
+
CLI_DIR,
|
|
1818
|
+
cmd.label,
|
|
1819
|
+
page
|
|
1820
|
+
)
|
|
1821
|
+
expect(ok).toBeTruthy()
|
|
1822
|
+
})
|
|
1823
|
+
}
|
|
1824
|
+
})
|