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.
Files changed (155) hide show
  1. package/dist/cli/index.js +1673 -696
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +16 -10
  4. package/template/.husky/pre-commit +1 -1
  5. package/template/modules.config.ts +29 -2
  6. package/template/package.json +12 -12
  7. package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
  8. package/template/playwright.config.ts +7 -1
  9. package/template/src/admin/App.tsx +2 -2
  10. package/template/src/admin/components/CaptchaModal.tsx +7 -9
  11. package/template/src/admin/layouts/Header.tsx +56 -22
  12. package/template/src/admin/layouts/Layout.tsx +14 -9
  13. package/template/src/admin/layouts/Sidebar.tsx +122 -105
  14. package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
  15. package/template/src/admin/pages/ContentPage.tsx +2 -0
  16. package/template/src/admin/pages/DashboardPage.tsx +2 -0
  17. package/template/src/admin/pages/DisputesPage.tsx +2 -0
  18. package/template/src/admin/pages/MediaTestPage.tsx +1 -1
  19. package/template/src/admin/pages/OrdersPage.tsx +2 -0
  20. package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
  21. package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
  22. package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
  23. package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
  24. package/template/src/admin/pages/TicketsPage.tsx +2 -0
  25. package/template/src/admin/pages/UsersPage.tsx +3 -2
  26. package/template/src/admin/stores/adminStore.ts +5 -0
  27. package/template/src/cli/index.ts +17 -19
  28. package/template/src/cli/modules/auth/index.ts +65 -0
  29. package/template/src/cli/modules/config/index.ts +74 -77
  30. package/template/src/cli/modules/index.ts +28 -6
  31. package/template/src/cli/modules/notification/index.ts +95 -79
  32. package/template/src/cli/modules/plugin/index.ts +111 -0
  33. package/template/src/cli/modules/todo/index.ts +99 -51
  34. package/template/src/cli/utils/auto-command.ts +7 -25
  35. package/template/src/cli/utils/index.ts +3 -1
  36. package/template/src/client/App.tsx +36 -16
  37. package/template/src/client/Layout.tsx +67 -10
  38. package/template/src/client/components/AuthButton.tsx +25 -18
  39. package/template/src/client/components/BottomTabBar.tsx +107 -0
  40. package/template/src/client/components/Navigation.tsx +162 -52
  41. package/template/src/client/components/__tests__/App.test.tsx +48 -32
  42. package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
  43. package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
  44. package/template/src/client/components/index.ts +1 -0
  45. package/template/src/client/contexts/PresetContext.tsx +10 -0
  46. package/template/src/client/main.tsx +63 -8
  47. package/template/src/client/pages/CartPage.tsx +244 -0
  48. package/template/src/client/pages/CategoriesPage.tsx +100 -0
  49. package/template/src/client/pages/ContentDetailPage.tsx +9 -14
  50. package/template/src/client/pages/ContentListPage.tsx +4 -11
  51. package/template/src/client/pages/DashboardPage.tsx +261 -0
  52. package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
  53. package/template/src/client/pages/LoginPage.tsx +127 -0
  54. package/template/src/client/pages/OrdersPage.tsx +196 -0
  55. package/template/src/client/pages/PluginDetailPage.tsx +345 -0
  56. package/template/src/client/pages/PluginsPage.tsx +223 -0
  57. package/template/src/client/pages/ProfilePage.tsx +206 -0
  58. package/template/src/client/pages/PublishPage.tsx +336 -0
  59. package/template/src/client/pages/RegisterPage.tsx +136 -0
  60. package/template/src/client/pages/SearchPage.tsx +204 -0
  61. package/template/src/client/pages/SettingsPage.tsx +220 -0
  62. package/template/src/client/pages/TopicsPage.tsx +180 -0
  63. package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
  64. package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
  65. package/template/src/client/preset-ui-config.ts +492 -0
  66. package/template/src/client/services/apiClient.ts +14 -4
  67. package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
  68. package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
  69. package/template/src/client/stores/authStore.ts +58 -5
  70. package/template/src/client/stores/chatWSStore.ts +9 -0
  71. package/template/src/client/stores/notificationStore.ts +4 -0
  72. package/template/src/client/stores/pluginStore.ts +279 -0
  73. package/template/src/client/stores/todoStore.ts +2 -6
  74. package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
  75. package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
  76. package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
  77. package/template/src/server/core/isr-cache.ts +239 -0
  78. package/template/src/server/core/isr-invalidation.ts +45 -0
  79. package/template/src/server/core/module-loader.ts +14 -7
  80. package/template/src/server/core/ssr-renderer.ts +240 -0
  81. package/template/src/server/db/init.ts +257 -10
  82. package/template/src/server/db/schema/developers.ts +20 -0
  83. package/template/src/server/db/schema/index.ts +2 -0
  84. package/template/src/server/db/schema/plugins.ts +114 -0
  85. package/template/src/server/db/test-setup.ts +91 -0
  86. package/template/src/server/entries/cloudflare.ts +79 -7
  87. package/template/src/server/entries/node.ts +48 -5
  88. package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
  89. package/template/src/server/middleware/auth.ts +8 -1
  90. package/template/src/server/middleware/captcha.ts +14 -4
  91. package/template/src/server/middleware/rate-limit.ts +7 -2
  92. package/template/src/server/module-admin/module.ts +9 -5
  93. package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
  94. package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
  95. package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
  96. package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
  97. package/template/src/server/module-admin/services/admin-service.ts +68 -11
  98. package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
  99. package/template/src/server/module-auth/index.ts +7 -0
  100. package/template/src/server/module-auth/module.ts +40 -0
  101. package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
  102. package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
  103. package/template/src/server/module-auth/services/auth-service.ts +100 -0
  104. package/template/src/server/module-content/module.ts +10 -4
  105. package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
  106. package/template/src/server/module-content/routes/topics-routes.ts +205 -0
  107. package/template/src/server/module-content/services/content-service.ts +18 -2
  108. package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
  109. package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
  110. package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
  111. package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
  112. package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
  113. package/template/src/server/module-order/module.ts +15 -0
  114. package/template/src/server/module-order/routes/cart-routes.ts +103 -0
  115. package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
  116. package/template/src/server/module-order/services/order-service.ts +1 -1
  117. package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
  118. package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
  119. package/template/src/server/module-plugin/index.ts +2 -0
  120. package/template/src/server/module-plugin/module.ts +52 -0
  121. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
  122. package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
  123. package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
  124. package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
  125. package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
  126. package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
  127. package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
  128. package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
  129. package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
  130. package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
  131. package/template/src/server/route-registry.ts +16 -1
  132. package/template/src/server/test-utils/test-client.ts +1 -2
  133. package/template/src/server/utils/auth.ts +6 -0
  134. package/template/src/server/utils/json.ts +13 -0
  135. package/template/src/shared/core/module-manifest.ts +3 -6
  136. package/template/src/shared/modules/auth/index.ts +12 -0
  137. package/template/src/shared/modules/auth/schemas.ts +50 -0
  138. package/template/src/shared/modules/cart/index.ts +1 -0
  139. package/template/src/shared/modules/cart/schemas.ts +41 -0
  140. package/template/src/shared/modules/community/index.ts +1 -0
  141. package/template/src/shared/modules/community/schemas.ts +58 -0
  142. package/template/src/shared/modules/dashboard/index.ts +1 -0
  143. package/template/src/shared/modules/dashboard/schemas.ts +35 -0
  144. package/template/src/shared/modules/index.ts +41 -0
  145. package/template/src/shared/modules/order/schemas.ts +29 -0
  146. package/template/src/shared/modules/plugins/index.ts +48 -0
  147. package/template/src/shared/modules/plugins/schemas.ts +227 -0
  148. package/template/src/shared/schemas/index.ts +130 -0
  149. package/template/tests/e2e/todo.spec.ts +23 -18
  150. package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
  151. package/template/uploads/.gitkeep +0 -0
  152. package/template/vite.config.ts +2 -1
  153. package/template/vitest.setup.ts +11 -0
  154. package/template/wrangler.toml +4 -3
  155. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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> &middot; ${
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
+ })