@h-ai/cli 0.1.0-alpha9 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (438) hide show
  1. package/README.en.md +229 -0
  2. package/README.ja.md +229 -0
  3. package/README.md +175 -113
  4. package/dist/chunk-AKV2CQZV.js +1497 -0
  5. package/dist/chunk-AKV2CQZV.js.map +1 -0
  6. package/dist/chunk-EF7WOFWZ.js +7557 -0
  7. package/dist/chunk-EF7WOFWZ.js.map +1 -0
  8. package/dist/chunk-MUHRBJSW.js +29272 -0
  9. package/dist/chunk-MUHRBJSW.js.map +1 -0
  10. package/dist/chunk-ZSDEVXZ7.js +38 -0
  11. package/dist/{chunk-2H7UOFLK.js.map → chunk-ZSDEVXZ7.js.map} +1 -1
  12. package/dist/cli-config-templates-HHFHNXBN.js +5 -0
  13. package/dist/cli-config-templates-HHFHNXBN.js.map +1 -0
  14. package/dist/cli-skill-templates-PN4FLGBC.js +5 -0
  15. package/dist/cli-skill-templates-PN4FLGBC.js.map +1 -0
  16. package/dist/index.js +4444 -554
  17. package/dist/index.js.map +1 -1
  18. package/dist/metafile-esm.json +1 -0
  19. package/dist/src-TEBHR5I5.js +5 -0
  20. package/dist/src-TEBHR5I5.js.map +1 -0
  21. package/package.json +35 -15
  22. package/templates/README.md +56 -114
  23. package/templates/apps/admin/messages/en-US.json +7 -0
  24. package/templates/apps/admin/messages/ja-JP.json +40 -0
  25. package/templates/apps/admin/messages/zh-CN.json +7 -0
  26. package/templates/apps/admin/src/routes/+layout.svelte.hbs +65 -8
  27. package/templates/apps/admin/src/routes/+page.svelte.hbs +35 -14
  28. package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +54 -22
  29. package/templates/apps/admin/src/routes/admin/+page.svelte.hbs +33 -24
  30. package/templates/apps/admin/src/routes/admin/settings/+page.svelte.hbs +16 -13
  31. package/templates/apps/api/.dockerignore.hbs +25 -0
  32. package/templates/apps/api/.gitignore.hbs +22 -0
  33. package/templates/apps/api/.npmrc.hbs +1 -0
  34. package/templates/apps/api/apps/{{projectName}}-contract/README.md.hbs +39 -0
  35. package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +41 -0
  36. package/templates/apps/api/apps/{{projectName}}-contract/src/app-contract.ts.hbs +27 -0
  37. package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +28 -0
  38. package/templates/apps/api/apps/{{projectName}}-contract/src/index.ts.hbs +5 -0
  39. package/templates/apps/api/apps/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +16 -0
  40. package/templates/apps/api/apps/{{projectName}}-contract/tests/contract.test.ts.hbs +18 -0
  41. package/templates/apps/api/apps/{{projectName}}-contract/tsconfig.json.hbs +12 -0
  42. package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +11 -0
  43. package/templates/apps/api/apps/{{projectName}}-contract/vitest.config.ts.hbs +9 -0
  44. package/templates/apps/api/apps/{{projectName}}-serv/README.md.hbs +60 -0
  45. package/templates/apps/api/apps/{{projectName}}-serv/config/_core.yml.hbs +17 -0
  46. package/templates/apps/api/apps/{{projectName}}-serv/config/_serv.yml.hbs +54 -0
  47. package/templates/apps/api/apps/{{projectName}}-serv/data/.gitkeep.hbs +1 -0
  48. package/templates/apps/api/apps/{{projectName}}-serv/package.json.hbs +66 -0
  49. package/templates/apps/api/apps/{{projectName}}-serv/src/app.ts.hbs +119 -0
  50. package/templates/apps/api/apps/{{projectName}}-serv/src/build-metadata.ts.hbs +7 -0
  51. package/templates/apps/api/apps/{{projectName}}-serv/src/index.ts.hbs +41 -0
  52. package/templates/apps/api/apps/{{projectName}}-serv/src/lib/server/init.ts.hbs +165 -0
  53. package/templates/apps/api/apps/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +57 -0
  54. package/templates/apps/api/apps/{{projectName}}-serv/src/server/procedures/index.ts.hbs +41 -0
  55. package/templates/apps/api/apps/{{projectName}}-serv/tests/service.test.ts.hbs +176 -0
  56. package/templates/apps/api/apps/{{projectName}}-serv/tsconfig.json.hbs +16 -0
  57. package/templates/apps/api/apps/{{projectName}}-serv/tsup.config.ts.hbs +20 -0
  58. package/templates/apps/api/apps/{{projectName}}-serv/vitest.config.ts.hbs +12 -0
  59. package/templates/apps/api/e2e/api-serv.spec.ts.hbs +113 -0
  60. package/templates/apps/api/e2e/capture-evidence.ts.hbs +23 -0
  61. package/templates/apps/api/eslint.config.js.hbs +30 -0
  62. package/templates/apps/api/hai.json.hbs +4 -0
  63. package/templates/apps/api/images/serv.Dockerfile.hbs +37 -0
  64. package/templates/apps/api/package.json.hbs +53 -0
  65. package/templates/apps/api/playwright.config.ts.hbs +67 -0
  66. package/templates/apps/api/pnpm-workspace.yaml.hbs +40 -0
  67. package/templates/apps/api/tsconfig.base.json +14 -0
  68. package/templates/apps/fullstack/.dockerignore.hbs +25 -0
  69. package/templates/apps/fullstack/.gitignore.hbs +38 -0
  70. package/templates/apps/fullstack/.npmrc.hbs +1 -0
  71. package/templates/apps/fullstack/apps/{{projectName}}-app/.env.example.hbs +10 -0
  72. package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +67 -0
  73. package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +20 -0
  74. package/templates/apps/fullstack/apps/{{projectName}}-app/eslint.config.js.hbs +20 -0
  75. package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +22 -0
  76. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +7 -0
  77. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/ja-JP.json.hbs +7 -0
  78. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +7 -0
  79. package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +86 -0
  80. package/templates/apps/fullstack/apps/{{projectName}}-app/project.inlang/settings.json.hbs +17 -0
  81. package/templates/apps/fullstack/apps/{{projectName}}-app/public/runtime-config.js.hbs +4 -0
  82. package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +59 -0
  83. package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.css.hbs +20 -0
  84. package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +8 -0
  85. package/templates/apps/fullstack/apps/{{projectName}}-app/src/build-metadata.ts.hbs +9 -0
  86. package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +19 -0
  87. package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +21 -0
  88. package/templates/apps/fullstack/apps/{{projectName}}-app/svelte.config.js.hbs +16 -0
  89. package/templates/apps/fullstack/apps/{{projectName}}-app/tsconfig.json.hbs +23 -0
  90. package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +74 -0
  91. package/templates/apps/fullstack/apps/{{projectName}}-desktop/.env.example.hbs +6 -0
  92. package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +64 -0
  93. package/templates/apps/fullstack/apps/{{projectName}}-desktop/eslint.config.js.hbs +20 -0
  94. package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +20 -0
  95. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +7 -0
  96. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/ja-JP.json.hbs +7 -0
  97. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +7 -0
  98. package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +66 -0
  99. package/templates/apps/fullstack/apps/{{projectName}}-desktop/project.inlang/settings.json.hbs +17 -0
  100. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +25 -0
  101. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/app.css.hbs +18 -0
  102. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/app.d.ts.hbs +8 -0
  103. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/build-metadata.ts.hbs +9 -0
  104. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/main.ts.hbs +20 -0
  105. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +33 -0
  106. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/build.rs.hbs +6 -0
  107. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/capabilities/default.json.hbs +16 -0
  108. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/icons/128x128.png +0 -0
  109. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/icons/128x128@2x.png +0 -0
  110. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/icons/32x32.png +0 -0
  111. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/icons/icon.icns +0 -0
  112. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/icons/icon.ico +0 -0
  113. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/src/lib.rs.hbs +91 -0
  114. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/src/main.rs.hbs +8 -0
  115. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +46 -0
  116. package/templates/apps/fullstack/apps/{{projectName}}-desktop/svelte.config.js.hbs +14 -0
  117. package/templates/apps/fullstack/apps/{{projectName}}-desktop/tsconfig.json.hbs +22 -0
  118. package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +89 -0
  119. package/templates/apps/fullstack/apps/{{projectName}}-miniapp/README.md.hbs +25 -0
  120. package/templates/apps/fullstack/apps/{{projectName}}-miniapp/messages/en-US.json.hbs +12 -0
  121. package/templates/apps/fullstack/apps/{{projectName}}-miniapp/messages/ja-JP.json.hbs +12 -0
  122. package/templates/apps/fullstack/apps/{{projectName}}-miniapp/messages/zh-CN.json.hbs +12 -0
  123. package/templates/apps/fullstack/apps/{{projectName}}-miniapp/project.inlang/settings.json.hbs +17 -0
  124. package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +54 -0
  125. package/templates/apps/fullstack/apps/{{projectName}}-web/eslint.config.js.hbs +20 -0
  126. package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +21 -0
  127. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +7 -0
  128. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/ja-JP.json.hbs +7 -0
  129. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +7 -0
  130. package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +53 -0
  131. package/templates/apps/fullstack/apps/{{projectName}}-web/project.inlang/settings.json.hbs +17 -0
  132. package/templates/apps/fullstack/apps/{{projectName}}-web/public/runtime-config.js.hbs +1 -0
  133. package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +18 -0
  134. package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.css.hbs +18 -0
  135. package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +8 -0
  136. package/templates/apps/fullstack/apps/{{projectName}}-web/src/build-metadata.ts.hbs +9 -0
  137. package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +14 -0
  138. package/templates/apps/fullstack/apps/{{projectName}}-web/svelte.config.js.hbs +16 -0
  139. package/templates/apps/fullstack/apps/{{projectName}}-web/tsconfig.json.hbs +23 -0
  140. package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +76 -0
  141. package/templates/apps/fullstack/docs/security-configuration.md.hbs +349 -0
  142. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +988 -0
  143. package/templates/apps/fullstack/e2e/session-close.spec.ts.hbs +119 -0
  144. package/templates/apps/fullstack/eslint.config.js.hbs +41 -0
  145. package/templates/apps/fullstack/hai.json.hbs +11 -0
  146. package/templates/apps/fullstack/images/serv.Dockerfile.hbs +38 -0
  147. package/templates/apps/fullstack/images/web/19-runtime-config.envsh.hbs +5 -0
  148. package/templates/apps/fullstack/images/web/nginx.conf.hbs +24 -0
  149. package/templates/apps/fullstack/images/web/runtime-config.js.template.hbs +3 -0
  150. package/templates/apps/fullstack/images/web.Dockerfile.hbs +37 -0
  151. package/templates/apps/fullstack/package.json.hbs +61 -0
  152. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +40 -0
  153. package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +30 -0
  154. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +78 -0
  155. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +134 -0
  156. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-contract.ts.hbs +20 -0
  157. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +35 -0
  158. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +7 -0
  159. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +23 -0
  160. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +31 -0
  161. package/templates/apps/fullstack/packages/{{projectName}}-contract/tsconfig.json.hbs +9 -0
  162. package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +9 -0
  163. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +84 -0
  164. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_ai.yml.hbs +1 -0
  165. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +1 -0
  166. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +1 -0
  167. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +22 -0
  168. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +1 -0
  169. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_feedback.yml.hbs +1 -0
  170. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +1 -0
  171. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_payment.yml.hbs +1 -0
  172. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +60 -0
  173. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_scheduler.yml.hbs +1 -0
  174. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +64 -0
  175. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +1 -0
  176. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_vecdb.yml.hbs +1 -0
  177. package/templates/apps/fullstack/packages/{{projectName}}-serv/data/.gitkeep.hbs +1 -0
  178. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +62 -0
  179. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +115 -0
  180. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/build-metadata.ts.hbs +7 -0
  181. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +40 -0
  182. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +294 -0
  183. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/audit-jobs.ts.hbs +28 -0
  184. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/business-settings.ts.hbs +50 -0
  185. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +50 -0
  186. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +106 -0
  187. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +49 -0
  188. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +34 -0
  189. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +159 -0
  190. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/security-roles.ts.hbs +73 -0
  191. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +505 -0
  192. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +72 -0
  193. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/security.test.ts.hbs +183 -0
  194. package/templates/apps/fullstack/packages/{{projectName}}-serv/tsconfig.json.hbs +10 -0
  195. package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +18 -0
  196. package/templates/apps/fullstack/packages/{{projectName}}-serv/vitest.config.ts.hbs +12 -0
  197. package/templates/apps/fullstack/packages/{{projectName}}-shared/.gitignore.hbs +10 -0
  198. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +97 -0
  199. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +164 -0
  200. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/ja-JP.json.hbs +164 -0
  201. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +164 -0
  202. package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +55 -0
  203. package/templates/apps/fullstack/packages/{{projectName}}-shared/project.inlang/settings.json.hbs +11 -0
  204. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +7 -0
  205. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +94 -0
  206. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/feedback-client.ts.hbs +36 -0
  207. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/sensitive-actions.svelte.ts.hbs +16 -0
  208. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/session-recovery.svelte.ts.hbs +4 -0
  209. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +122 -0
  210. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +90 -0
  211. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/FeedbackEntry.svelte.hbs +86 -0
  212. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/audit.svelte.hbs +94 -0
  213. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/configuration.svelte.hbs +25 -0
  214. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/feedback.svelte.hbs +157 -0
  215. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/roles.svelte.hbs +77 -0
  216. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/settings.svelte.hbs +44 -0
  217. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/pages/users.svelte.hbs +216 -0
  218. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/security/role-label.ts.hbs +12 -0
  219. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/guide-store.ts.hbs +55 -0
  220. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +614 -0
  221. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +23 -0
  222. package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
  223. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +150 -0
  224. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +253 -0
  225. package/templates/apps/fullstack/packages/{{projectName}}-shared/tsconfig.json.hbs +17 -0
  226. package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
  227. package/templates/apps/fullstack/playwright.config.ts.hbs +114 -0
  228. package/templates/apps/fullstack/playwright.session-close.config.ts.hbs +18 -0
  229. package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +38 -0
  230. package/templates/apps/fullstack/tsconfig.base.json +14 -0
  231. package/templates/apps/h5/messages/en-US.json +3 -0
  232. package/templates/apps/h5/messages/ja-JP.json +40 -0
  233. package/templates/apps/h5/messages/zh-CN.json +3 -0
  234. package/templates/apps/h5/src/routes/+layout.svelte.hbs +85 -30
  235. package/templates/apps/h5/src/routes/+page.svelte.hbs +20 -8
  236. package/templates/apps/h5/src/routes/discover/+page.svelte.hbs +17 -6
  237. package/templates/apps/h5/src/routes/profile/+page.svelte.hbs +17 -6
  238. package/templates/apps/mobile-app/.env.hbs +3 -0
  239. package/templates/apps/{android-app → mobile-app}/capacitor.config.ts.hbs +2 -2
  240. package/templates/apps/mobile-app/index.html.hbs +14 -0
  241. package/templates/apps/mobile-app/messages/en-US.json +36 -0
  242. package/templates/apps/mobile-app/messages/ja-JP.json +36 -0
  243. package/templates/apps/mobile-app/messages/zh-CN.json +36 -0
  244. package/templates/apps/mobile-app/public/runtime-config.js +1 -0
  245. package/templates/apps/mobile-app/src/App.svelte.hbs +153 -0
  246. package/templates/apps/mobile-app/src/lib/api.ts.hbs +213 -0
  247. package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +14 -0
  248. package/templates/apps/{android-app → mobile-app}/src/lib/capacitor.ts +6 -7
  249. package/templates/apps/mobile-app/src/lib/pages/home.svelte.hbs +58 -0
  250. package/templates/apps/mobile-app/src/lib/pages/profile.svelte.hbs +13 -0
  251. package/templates/apps/mobile-app/src/lib/pages/tasks.svelte.hbs +16 -0
  252. package/templates/apps/mobile-app/src/main.ts.hbs +11 -0
  253. package/templates/apps/website/messages/en-US.json +4 -2
  254. package/templates/apps/website/messages/ja-JP.json +34 -0
  255. package/templates/apps/website/messages/zh-CN.json +4 -2
  256. package/templates/apps/website/scripts/static-website.mjs.hbs +67 -0
  257. package/templates/apps/website/src/routes/+layout.svelte.hbs +77 -26
  258. package/templates/apps/website/src/routes/+layout.ts +3 -0
  259. package/templates/apps/website/src/routes/+page.svelte.hbs +31 -12
  260. package/templates/apps/website/src/routes/about/+page.svelte.hbs +11 -6
  261. package/templates/apps/website/src/routes/api/health/+server.ts +3 -0
  262. package/templates/apps/website/src/routes/contact/+page.svelte.hbs +44 -34
  263. package/templates/apps/website/src/routes/services/+page.svelte.hbs +27 -40
  264. package/templates/base/.dockerignore.hbs +27 -0
  265. package/templates/base/.gitignore.hbs +41 -0
  266. package/templates/base/.npmrc.hbs +1 -0
  267. package/templates/base/e2e/app.spec.ts.hbs +213 -0
  268. package/templates/base/e2e/helpers.ts +1 -1
  269. package/templates/base/eslint-rules.js +45 -0
  270. package/templates/base/eslint.config.js.hbs +34 -0
  271. package/templates/base/hai.json.hbs +4 -0
  272. package/templates/base/images/web/nginx.conf.hbs +24 -0
  273. package/templates/base/images/web.Dockerfile.hbs +72 -0
  274. package/templates/base/package.json.hbs +125 -49
  275. package/templates/base/playwright.config.ts +44 -6
  276. package/templates/base/pnpm-workspace.yaml.hbs +40 -0
  277. package/templates/base/src/app.css.hbs +40 -0
  278. package/templates/base/src/app.d.ts.hbs +14 -1
  279. package/templates/base/src/app.html.hbs +13 -0
  280. package/templates/base/src/hooks.server.ts.hbs +115 -43
  281. package/templates/base/src/lib/build-metadata.ts.hbs +7 -0
  282. package/templates/base/src/lib/server/init.ts.hbs +125 -152
  283. package/templates/base/src/lib/server/kit-config.ts.hbs +40 -0
  284. package/templates/base/src/routes/+layout.server.ts.hbs +35 -0
  285. package/templates/base/static/favicon.png +0 -0
  286. package/templates/base/svelte.config.js.hbs +84 -5
  287. package/templates/base/tests/scaffold.test.ts.hbs +102 -0
  288. package/templates/base/tsconfig.json.hbs +50 -0
  289. package/templates/base/vite.config.ts.hbs +117 -12
  290. package/templates/base/vitest.config.ts.hbs +19 -0
  291. package/templates/features/ai/routes/api/chat/+server.ts +6 -7
  292. package/templates/features/iam/routes-admin/(auth)/+layout.svelte.hbs +3 -17
  293. package/templates/features/iam/routes-admin/(auth)/auth/forgot-password/+page.svelte.hbs +10 -0
  294. package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +9 -96
  295. package/templates/features/iam/routes-admin/(auth)/auth/register/+page.svelte.hbs +9 -122
  296. package/templates/features/iam/routes-admin/(auth)/auth/reset-password/+page.svelte.hbs +10 -0
  297. package/templates/features/iam/routes-h5/auth/forgot-password/+page.svelte.hbs +10 -0
  298. package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +9 -96
  299. package/templates/features/iam/routes-h5/auth/register/+page.svelte.hbs +9 -112
  300. package/templates/features/iam/routes-h5/auth/reset-password/+page.svelte.hbs +10 -0
  301. package/templates/features/iam/routes-shared/api/auth/captcha/+server.ts.hbs +4 -0
  302. package/templates/features/iam/routes-shared/api/auth/config/+server.ts +4 -0
  303. package/templates/features/iam/routes-shared/api/auth/login/+server.ts +1 -29
  304. package/templates/features/iam/routes-shared/api/auth/login/ldap/+server.ts +4 -0
  305. package/templates/features/iam/routes-shared/api/auth/login/otp/+server.ts +4 -0
  306. package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +13 -6
  307. package/templates/features/iam/routes-shared/api/auth/otp/send/+server.ts +4 -0
  308. package/templates/features/iam/routes-shared/api/auth/password/renew/+server.ts +5 -0
  309. package/templates/features/iam/routes-shared/api/auth/password-reset/confirm/+server.ts +4 -0
  310. package/templates/features/iam/routes-shared/api/auth/password-reset/request/+server.ts +4 -0
  311. package/templates/features/iam/routes-shared/api/auth/reauthenticate/+server.ts +5 -0
  312. package/templates/features/iam/routes-shared/api/auth/register/+server.ts +1 -23
  313. package/templates/features/iam/routes-shared/api/auth/session/close/+server.ts.hbs +4 -0
  314. package/templates/features/iam/routes-website/auth/forgot-password/+page.svelte.hbs +10 -0
  315. package/templates/features/iam/routes-website/auth/login/+page.svelte.hbs +11 -0
  316. package/templates/features/iam/routes-website/auth/register/+page.svelte.hbs +11 -0
  317. package/templates/features/iam/routes-website/auth/reset-password/+page.svelte.hbs +10 -0
  318. package/templates/features/payment/routes/api/v1/payment/create/+server.ts +25 -23
  319. package/templates/features/payment/routes/api/v1/payment/notify/[provider]/+server.ts +5 -2
  320. package/templates/features/storage/routes/api/upload/+server.ts +10 -8
  321. package/templates/i18n/project.inlang/settings.json +2 -2
  322. package/templates/shared/.gitattributes +11 -0
  323. package/templates/shared/build-metadata.ts +42 -0
  324. package/templates/shared/container-runtime-config.sh +30 -0
  325. package/templates/shared/e2e-env.ts +98 -0
  326. package/templates/shared/hash-router.svelte.ts +83 -0
  327. package/templates/shared/public-base-path.mjs +19 -0
  328. package/templates/shared/run-e2e.mjs +104 -0
  329. package/templates/shared/vite-runtime-config.ts +56 -0
  330. package/templates/skills/AGENTS.md +75 -16
  331. package/templates/skills/CLAUDE.md +0 -11
  332. package/templates/skills/bridges/admin/AGENTS.md +89 -0
  333. package/templates/skills/bridges/api/AGENTS.md +83 -0
  334. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +102 -0
  335. package/templates/skills/bridges/h5/AGENTS.md +88 -0
  336. package/templates/skills/bridges/mobile-app/AGENTS.md +100 -0
  337. package/templates/skills/bridges/website/AGENTS.md +88 -0
  338. package/templates/skills/copilot-instructions.md +3 -59
  339. package/templates/skills/hai-ai/SKILL.md +44 -715
  340. package/templates/skills/hai-ai/guide.md +457 -0
  341. package/templates/skills/hai-ai/reference.md +292 -0
  342. package/templates/skills/hai-api-client/SKILL.md +26 -339
  343. package/templates/skills/hai-api-client/reference.md +180 -0
  344. package/templates/skills/hai-api-contract/SKILL.md +36 -0
  345. package/templates/skills/hai-api-contract/reference.md +280 -0
  346. package/templates/skills/hai-app-create/SKILL.md +38 -351
  347. package/templates/skills/hai-app-create/collaboration.md +52 -0
  348. package/templates/skills/hai-app-create/development.md +243 -0
  349. package/templates/skills/hai-app-create/reference.md +376 -0
  350. package/templates/skills/hai-app-review/SKILL.md +57 -126
  351. package/templates/skills/hai-app-review/differential.md +34 -0
  352. package/templates/skills/hai-app-tests/SKILL.md +34 -454
  353. package/templates/skills/hai-app-tests/reference.md +462 -0
  354. package/templates/skills/hai-app-tests/validation.md +42 -0
  355. package/templates/skills/hai-audit/SKILL.md +37 -122
  356. package/templates/skills/hai-audit/reference.md +184 -0
  357. package/templates/skills/hai-build/SKILL.md +62 -315
  358. package/templates/skills/hai-build/paths.md +30 -0
  359. package/templates/skills/hai-build/reference.md +353 -0
  360. package/templates/skills/hai-cache/SKILL.md +26 -175
  361. package/templates/skills/hai-cache/reference.md +229 -0
  362. package/templates/skills/hai-capacitor/SKILL.md +24 -364
  363. package/templates/skills/hai-capacitor/reference.md +358 -0
  364. package/templates/skills/hai-change-plan/SKILL.md +46 -0
  365. package/templates/skills/hai-ci/SKILL.md +114 -0
  366. package/templates/skills/hai-core/SKILL.md +31 -469
  367. package/templates/skills/hai-core/reference.md +557 -0
  368. package/templates/skills/hai-core/request-access.md +65 -0
  369. package/templates/skills/hai-crypto/SKILL.md +29 -180
  370. package/templates/skills/hai-crypto/reference.md +317 -0
  371. package/templates/skills/hai-datapipe/SKILL.md +24 -207
  372. package/templates/skills/hai-datapipe/reference.md +204 -0
  373. package/templates/skills/hai-deploy/SKILL.md +53 -178
  374. package/templates/skills/hai-deploy/credentials.md +19 -0
  375. package/templates/skills/hai-deploy/middleware.md +129 -0
  376. package/templates/skills/hai-feedback/SKILL.md +85 -0
  377. package/templates/skills/hai-framework-sync/SKILL.md +105 -0
  378. package/templates/skills/hai-framework-sync/sync.md +49 -0
  379. package/templates/skills/hai-iam/SKILL.md +53 -325
  380. package/templates/skills/hai-iam/reference.md +535 -0
  381. package/templates/skills/hai-kit/SKILL.md +33 -329
  382. package/templates/skills/hai-kit/reference.md +202 -0
  383. package/templates/skills/hai-payment/SKILL.md +28 -262
  384. package/templates/skills/hai-payment/reference.md +295 -0
  385. package/templates/skills/hai-pr-review/SKILL.md +41 -0
  386. package/templates/skills/hai-reach/SKILL.md +28 -300
  387. package/templates/skills/hai-reach/reference.md +363 -0
  388. package/templates/skills/hai-reldb/SKILL.md +28 -449
  389. package/templates/skills/hai-reldb/reference.md +500 -0
  390. package/templates/skills/hai-scheduler/SKILL.md +26 -174
  391. package/templates/skills/hai-scheduler/reference.md +216 -0
  392. package/templates/skills/hai-serv/SKILL.md +60 -0
  393. package/templates/skills/hai-serv/reference.md +792 -0
  394. package/templates/skills/hai-spec-implement/SKILL.md +50 -0
  395. package/templates/skills/hai-spec-implement/changes.md +67 -0
  396. package/templates/skills/hai-spec-implement/evaluation.md +18 -0
  397. package/templates/skills/hai-spec-implement/handoff.md +47 -0
  398. package/templates/skills/hai-spec-implement/templates.md +117 -0
  399. package/templates/skills/hai-spec-implement/workflow.md +56 -0
  400. package/templates/skills/hai-spec-review/SKILL.md +57 -0
  401. package/templates/skills/hai-spec-review/checklist.md +71 -0
  402. package/templates/skills/hai-storage/SKILL.md +31 -262
  403. package/templates/skills/hai-storage/reference.md +284 -0
  404. package/templates/skills/hai-ui/SKILL.md +62 -238
  405. package/templates/skills/hai-ui/guide.md +81 -0
  406. package/templates/skills/hai-ui/reference.md +705 -0
  407. package/templates/skills/hai-ui-design/SKILL.md +31 -0
  408. package/templates/skills/hai-ui-design/patterns.md +31 -0
  409. package/templates/skills/hai-ui-review/SKILL.md +32 -0
  410. package/templates/skills/hai-ui-review/checklist.md +29 -0
  411. package/templates/skills/hai-vecdb/SKILL.md +25 -267
  412. package/templates/skills/hai-vecdb/reference.md +325 -0
  413. package/templates/skills/opencode.json +0 -5
  414. package/dist/chunk-2H7UOFLK.js +0 -10
  415. package/dist/chunk-LANIDJC7.js +0 -659
  416. package/dist/chunk-LANIDJC7.js.map +0 -1
  417. package/dist/chunk-YV7WPUI6.js +0 -206
  418. package/dist/chunk-YV7WPUI6.js.map +0 -1
  419. package/dist/config-templates-S5RI2MC6.js +0 -5
  420. package/dist/config-templates-S5RI2MC6.js.map +0 -1
  421. package/dist/skill-templates-N5DU5FNX.js +0 -5
  422. package/dist/skill-templates-N5DU5FNX.js.map +0 -1
  423. package/dist/src-XJ6YXNAF.js +0 -15030
  424. package/dist/src-XJ6YXNAF.js.map +0 -1
  425. package/templates/apps/android-app/.env.hbs +0 -3
  426. package/templates/apps/android-app/messages/en-US.json +0 -24
  427. package/templates/apps/android-app/messages/zh-CN.json +0 -24
  428. package/templates/apps/android-app/src/lib/api.ts.hbs +0 -30
  429. package/templates/apps/android-app/src/routes/+layout.svelte.hbs +0 -42
  430. package/templates/apps/android-app/src/routes/+layout.ts +0 -7
  431. package/templates/apps/android-app/src/routes/+page.svelte.hbs +0 -27
  432. package/templates/apps/api/src/routes/+layout.svelte +0 -14
  433. package/templates/apps/api/src/routes/+page.svelte +0 -11
  434. package/templates/apps/api/src/routes/api/v1/health/+server.ts +0 -14
  435. package/templates/apps/api/src/routes/api/v1/items/+server.ts +0 -37
  436. package/templates/apps/api/src/routes/api/v1/items/[id]/+server.ts +0 -30
  437. package/templates/base/src/app.css +0 -2
  438. package/templates/base/tsconfig.json +0 -8
@@ -0,0 +1,988 @@
1
+ import type { Page } from '@playwright/test'
2
+ import { Buffer } from 'node:buffer'
3
+ {{#if features.feedback}}
4
+ import { execFileSync } from 'node:child_process'
5
+ {{/if}}
6
+ import { randomUUID } from 'node:crypto'
7
+ import { mkdir{{#if features.feedback}}, readFile{{/if}} } from 'node:fs/promises'
8
+ import path from 'node:path'
9
+ import process from 'node:process'
10
+ import { apiContract } from '@h-ai/api-contract'
11
+ {{#if features.feedback}}
12
+ import { readFeedbackBundle } from '@h-ai/feedback/bundle'
13
+ {{/if}}
14
+ import { expect, test } from '@playwright/test'
15
+ import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
16
+
17
+ const apiBaseUrl = process.env.SERVICE_BASE_URL || 'http://127.0.0.1:3000'
18
+ const apiPrefix = '/api/v1'
19
+ const adminPassword = process.env.E2E_ADMIN_PASSWORD || 'Scaffold-test-Admin123!'
20
+ const auditAdminPassword = process.env.E2E_AUDIT_ADMIN_PASSWORD || 'Scaffold-test-Audit123!'
21
+ const configAdminPassword = process.env.E2E_CONFIG_ADMIN_PASSWORD || 'Scaffold-test-Config123!'
22
+ {{#if features.feedback}}
23
+ const businessOperatorPassword = process.env.E2E_BUSINESS_OPERATOR_PASSWORD || 'Scaffold-test-Operator123!'
24
+ {{/if}}
25
+ const configuredName = process.env.HAI_CORE_NAME || 'Configured scaffold'
26
+ const configuredVersion = process.env.HAI_CORE_VERSION || '9.8.7'
27
+ const configuredEnv = 'test'
28
+
29
+ // 测试专用的一像素 PNG;上传仍使用完整前后端加密链路。
30
+ const avatar = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aSxkAAAAASUVORK5CYII=', 'base64')
31
+
32
+ async function capture(page: Page, name: string, fallback: string) {
33
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
34
+ if (evidence)
35
+ await mkdir(evidence, { recursive: true })
36
+ await expect(page.getByTestId('guide-loading')).toHaveCount(0)
37
+ await page.evaluate(() => document.fonts.ready)
38
+ await page.screenshot({ path: evidence ? path.join(evidence, name) : fallback, fullPage: true, animations: 'disabled' })
39
+ }
40
+
41
+ async function dismissToasts(page: Page) {
42
+ const dismiss = page.locator('.hai-toast-layer').getByRole('button', { name: /^(关闭|Dismiss|閉じる)$/ })
43
+ for (let count = 0; count < 10 && await dismiss.count(); count++)
44
+ await dismiss.click()
45
+ }
46
+
47
+ async function submitLogin(page: Page, username: string, password: string) {
48
+ await dismissToasts(page)
49
+ await expect(page.getByTestId('login-form')).toBeVisible()
50
+ await page.locator('#login-username').fill(username)
51
+ await page.getByTestId('login-form').locator('input[type="password"]').fill(password)
52
+ await page.getByTestId('login-form').getByRole('button', { name: /登录|Sign in|Login|Log in/i }).click()
53
+ }
54
+
55
+ async function login(page: Page, username: string, password: string, keepGuide = false) {
56
+ await submitLogin(page, username, password)
57
+ await expect(page.getByTestId('logout')).toBeVisible()
58
+ if (!keepGuide && await page.getByTestId('guide-later').isVisible())
59
+ await page.getByTestId('guide-later').click()
60
+ }
61
+
62
+ async function userEditor(page: Page, username: string) {
63
+ await page.getByTestId('users-search').locator('input').fill(username)
64
+ await page.getByTestId('users-page').getByRole('button', { name: /查询|Search/ }).click()
65
+ const row = page.getByRole('row').filter({ has: page.getByText(`@${username}`, { exact: true }) })
66
+ await expect(row).toBeVisible()
67
+ await row.getByRole('button', { name: /编辑|Edit/, exact: true }).click()
68
+ return row
69
+ }
70
+
71
+ for (const scenario of [{ offset: -600_000, width: 1440 }, { offset: 600_000, width: 390 }]) {
72
+ test(`clock offset ${scenario.offset} displays synchronization advice and recovers after correction`, async ({ page }, testInfo) => {
73
+ await page.setViewportSize({ width: scenario.width, height: 900 })
74
+ await page.addInitScript((offset) => {
75
+ const now = Date.now.bind(Date)
76
+ Date.now = () => now() + offset
77
+ }, scenario.offset)
78
+ await page.goto('./')
79
+ const advice = page.getByRole('alert').filter({ hasText: /同步|synchron|時刻/i })
80
+ await expect(advice).toBeVisible()
81
+ await expect(advice).toContainText(/刷新|refresh|再読み込み/i)
82
+ await capture(page, `clock-skew-${scenario.width}.png`, testInfo.outputPath(`clock-skew-${scenario.width}.png`))
83
+ // 校时后由用户明确刷新;不能自动重放之前失败的业务请求。
84
+ await page.evaluate((offset) => {
85
+ const now = Date.now.bind(Date)
86
+ Date.now = () => now() - offset
87
+ }, scenario.offset)
88
+ await page.getByRole('button', { name: /刷新|Refresh|再読み込み/i }).click()
89
+ await expect(page.getByTestId('login-form')).toBeVisible()
90
+ await expect(advice).toHaveCount(0)
91
+ })
92
+ }
93
+
94
+ test('service health endpoint is ready', async ({ request }) => {
95
+ const response = await request.get(`${apiBaseUrl}/health`)
96
+ await expect(response).toBeOK()
97
+ })
98
+
99
+ test('service rejects plaintext business requests', async ({ request }) => {
100
+ const response = await request.post(`${apiBaseUrl}${apiPrefix}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, { data: { message: 'hello e2e' } })
101
+ expect(response.status()).toBe(400)
102
+ })
103
+
104
+ {{#if fullstack.e2eFrontend}}
105
+ test('hash routing and responsive workspace preserve usable content width', async ({ page }, testInfo) => {
106
+ await page.goto('./#/roles?source=direct')
107
+ await login(page, 'admin', adminPassword)
108
+ await expect(page.getByTestId('roles-page')).toBeVisible()
109
+ await expect(page).toHaveURL(/#\/roles\?source=direct$/)
110
+ await page.reload()
111
+ await expect(page.getByTestId('roles-page')).toBeVisible()
112
+ await expect(page.locator('header')).toBeVisible()
113
+ await expect(page.locator('footer')).toBeVisible()
114
+ for (const width of [1440, 1920, 2560, 3840, 390]) {
115
+ await page.setViewportSize({ width, height: 960 })
116
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
117
+ const main = await page.locator('main').boundingBox()
118
+ const content = await page.getByTestId('roles-page').boundingBox()
119
+ expect(content!.width).toBeGreaterThan(main!.width - 80)
120
+ if (width === 1920)
121
+ await capture(page, '{{projectName}}-wide.png', testInfo.outputPath('wide-1920.png'))
122
+ }
123
+ await page.setViewportSize({ width: 1440, height: 960 })
124
+ await page.getByTestId('nav-settings').click()
125
+ await expect(page).toHaveURL(/#\/settings$/)
126
+ await page.goBack()
127
+ await expect(page.getByTestId('roles-page')).toBeVisible()
128
+ await page.goForward()
129
+ await expect(page.getByTestId('profile-form')).toBeVisible()
130
+ await page.goto('./#/unknown-page')
131
+ await expect(page).toHaveURL(/#\/users$/)
132
+ await expect(page.getByTestId('users-page')).toBeVisible()
133
+ const usersPage = page.getByTestId('users-page')
134
+ const accountHeader = usersPage.locator('th').first()
135
+ await accountHeader.getByRole('button').click()
136
+ await expect(accountHeader).toHaveAttribute('aria-sort', 'ascending')
137
+ const search = usersPage.getByTestId('users-search').locator('input')
138
+ await search.fill('unsubmitted-query-draft')
139
+ const sorted = page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/admin/users/list'))
140
+ await accountHeader.getByRole('button').click()
141
+ await sorted
142
+ await expect(usersPage.locator('tbody')).toContainText('admin')
143
+ await search.press('Enter')
144
+ await expect(usersPage.locator('tbody')).not.toContainText('admin')
145
+ const reset = page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/admin/users/list'))
146
+ await usersPage.getByTestId('users-filters').getByRole('button', { name: /^(重置|Reset|リセット)$/ }).click()
147
+ await reset
148
+ await expect(search).toHaveValue('')
149
+ await expect(usersPage.locator('tbody')).toContainText('admin')
150
+ for (const width of [390, 1440, 1920, 2560, 3840]) {
151
+ await page.setViewportSize({ width, height: 900 })
152
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
153
+ await expect(usersPage.locator('.hai-pagination__navigation')).toBeVisible()
154
+ await capture(page, `users-unified-${width}.png`, testInfo.outputPath(`users-unified-${width}.png`))
155
+ }
156
+ })
157
+
158
+ test('branded authentication, profile layout and blur validation work across screen sizes', async ({ page }, testInfo) => {
159
+ test.setTimeout(60_000)
160
+ await page.setViewportSize({ width: 1440, height: 960 })
161
+ await page.goto('./')
162
+ await expect(page.getByRole('heading', { name: configuredName, exact: true })).toBeVisible()
163
+ await expect(page.locator('header')).toBeVisible()
164
+ await expect(page.locator('footer')).toBeVisible()
165
+ await capture(page, '{{projectName}}-login-desktop.png', testInfo.outputPath('login-desktop.png'))
166
+ await page.getByRole('button', { name: /立即注册|Register now/, exact: true }).click()
167
+ await expect(page.locator('.register-form')).toBeVisible()
168
+ await capture(page, '{{projectName}}-register-desktop.png', testInfo.outputPath('register-desktop.png'))
169
+ await page.setViewportSize({ width: 390, height: 844 })
170
+ await capture(page, '{{projectName}}-register-mobile.png', testInfo.outputPath('register-mobile.png'))
171
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
172
+ await page.reload()
173
+ await expect(page.getByTestId('login-form')).toBeVisible()
174
+ await capture(page, '{{projectName}}-login-mobile.png', testInfo.outputPath('login-mobile.png'))
175
+ await page.setViewportSize({ width: 1440, height: 960 })
176
+ await login(page, 'admin', adminPassword)
177
+ await page.getByTestId('nav-settings').click()
178
+ const profile = page.getByTestId('profile-form')
179
+ await expect(profile).toBeVisible()
180
+ const email = profile.locator('input[name="email"]')
181
+ await email.fill('invalid-email')
182
+ await email.press('Tab')
183
+ await expect(email).toHaveAttribute('aria-invalid', 'true')
184
+ await capture(page, '{{projectName}}-profile-blur-error.png', testInfo.outputPath('profile-blur-error.png'))
185
+ await email.fill('')
186
+ await expect(email).not.toHaveAttribute('aria-invalid')
187
+ await capture(page, '{{projectName}}-settings-desktop.png', testInfo.outputPath('settings-desktop.png'))
188
+ for (const width of [768, 390]) {
189
+ await page.setViewportSize({ width, height: 960 })
190
+ const fields = await profile.locator('.hai-form-field').evaluateAll(elements => elements.map((field) => {
191
+ const label = field.querySelector('label')!.getBoundingClientRect()
192
+ const control = field.querySelector('input')!.getBoundingClientRect()
193
+ return { labelBottom: label.bottom, controlTop: control.top, controlRight: control.right }
194
+ }))
195
+ for (const field of fields) {
196
+ expect(field.labelBottom).toBeLessThanOrEqual(field.controlTop)
197
+ expect(field.controlRight).toBeLessThanOrEqual(width)
198
+ }
199
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
200
+ await capture(page, `{{projectName}}-settings-${width}.png`, testInfo.outputPath(`settings-${width}.png`))
201
+ }
202
+ await page.getByTestId('password-form').scrollIntoViewIfNeeded()
203
+ await capture(page, '{{projectName}}-password-mobile.png', testInfo.outputPath('password-mobile.png'))
204
+ await page.setViewportSize({ width: 1440, height: 960 })
205
+ await page.getByTestId('nav-users').click()
206
+ await page.getByTestId('create-user').click()
207
+ const username = page.getByTestId('user-username').locator('input')
208
+ await username.focus()
209
+ await username.press('Tab')
210
+ await expect(username).not.toHaveAttribute('aria-invalid')
211
+ await username.fill('draft')
212
+ await username.fill('')
213
+ await username.press('Tab')
214
+ await expect(username).toHaveAttribute('aria-invalid', 'true')
215
+ await username.fill('a')
216
+ await expect(username).toHaveAttribute('aria-invalid', 'true')
217
+ await page.getByTestId('user-password').locator('input').fill('short')
218
+ await page.getByTestId('user-password').locator('input').press('Tab')
219
+ await expect(page.getByTestId('user-password').locator('input')).toHaveAttribute('aria-invalid', 'true')
220
+ await capture(page, '{{projectName}}-create-blur-error.png', testInfo.outputPath('create-blur-error.png'))
221
+ await page.getByTestId('user-editor').getByRole('button', { name: /取消|Cancel/, exact: true }).click()
222
+ await page.getByTestId('create-user').click()
223
+ await expect(page.getByTestId('user-editor').locator('[aria-invalid="true"]')).toHaveCount(0)
224
+ await page.getByTestId('user-editor').getByRole('button', { name: /取消|Cancel/, exact: true }).click()
225
+ await userEditor(page, 'admin')
226
+ const editEmail = page.getByTestId('user-email').locator('input')
227
+ await editEmail.fill('invalid-email')
228
+ await editEmail.press('Tab')
229
+ await expect(editEmail).toHaveAttribute('aria-invalid', 'true')
230
+ await capture(page, '{{projectName}}-edit-blur-error.png', testInfo.outputPath('edit-blur-error.png'))
231
+ await editEmail.fill('admin@example.com')
232
+ await expect(editEmail).not.toHaveAttribute('aria-invalid')
233
+ })
234
+
235
+ test('first-use tour, help search and settings navigation work on desktop and mobile', async ({ page }, testInfo) => {
236
+ await page.goto('./')
237
+ await login(page, 'admin', adminPassword, true)
238
+ await expect(page.getByTestId('guide-offer')).toBeVisible()
239
+ await page.getByTestId('guide-start').click()
240
+ const popover = page.locator('.hai-guide-popover')
241
+ await expect(popover).toBeVisible()
242
+ await capture(page, '{{projectName}}-guide-desktop.png', testInfo.outputPath('guide-desktop.png'))
243
+ for (let step = 1; step < 3; step++) {
244
+ await popover.locator('.driver-popover-next-btn').click()
245
+ await expect(popover.locator('.driver-popover-progress-text')).toContainText(String(step + 1))
246
+ }
247
+ await popover.locator('.driver-popover-next-btn').click()
248
+ await expect(popover).toHaveCount(0)
249
+ await page.reload()
250
+ await expect(page.getByTestId('admin-help')).toBeVisible()
251
+ await expect(page.getByTestId('guide-offer')).toHaveCount(0)
252
+ await page.getByTestId('admin-help').click()
253
+ const profileTitle = (await page.getByTestId('guide-article-profile').textContent())!.trim()
254
+ await page.getByTestId('guide-search').locator('input').fill(profileTitle)
255
+ await expect(page.getByTestId('guide-article-profile')).toBeVisible()
256
+ await expect(page.getByTestId('guide-article-overview')).toHaveCount(0)
257
+ await page.getByTestId('guide-article-start').click()
258
+ await expect(page.getByTestId('settings-page')).toBeVisible()
259
+ await expect(popover).toBeVisible()
260
+ await page.keyboard.press('Escape')
261
+ await expect(popover).toHaveCount(0)
262
+ await page.setViewportSize({ width: 390, height: 844 })
263
+ await page.getByTestId('admin-help').click()
264
+ await expect(page.getByTestId('help-center')).toBeVisible()
265
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
266
+ await capture(page, '{{projectName}}-guide-help-mobile.png', testInfo.outputPath('guide-help-mobile.png'))
267
+ await page.getByTestId('guide-tour-overview').click()
268
+ await expect(popover.locator('.driver-popover-next-btn')).toBeEnabled()
269
+ // 下一步未渲染时仍能通过宿主提示取消,不能被引擎遮罩阻止。
270
+ await page.locator('[data-guide="admin-profile"]').evaluate(element => element.removeAttribute('data-guide'))
271
+ await popover.locator('.driver-popover-next-btn').click()
272
+ await expect(page.getByTestId('guide-loading')).toBeVisible()
273
+ await page.getByTestId('guide-cancel').click()
274
+ await expect(popover).toHaveCount(0)
275
+ await expect(page.getByTestId('guide-error')).toHaveCount(0)
276
+ })
277
+
278
+ // 所有已生成的浏览器前端都验证共享引导,不能用 Web 的成功代替 App/Desktop 预览。
279
+ const additionalFrontends = [
280
+ {{#each fullstack.frontendApps}}
281
+ {
282
+ target: '{{target}}',
283
+ url: process.env['HAI_E2E_{{target}}_URL'.toUpperCase()] || 'http://127.0.0.1:{{port}}',
284
+ },
285
+ {{/each}}
286
+ ].filter(frontend => frontend.target !== '{{fullstack.e2eFrontend.target}}')
287
+
288
+ for (const frontend of additionalFrontends) {
289
+ test(`${frontend.target} preview supports login, tour, help and profile navigation`, async ({ browser }, testInfo) => {
290
+ const page = await browser.newPage({ viewport: frontend.target === 'app' ? { width: 390, height: 844 } : { width: 1440, height: 960 } })
291
+ const pageErrors: string[] = []
292
+ page.on('pageerror', error => pageErrors.push(error.message))
293
+ try {
294
+ await page.goto(frontend.url)
295
+ await expect(page.getByTestId('login-form')).toBeVisible()
296
+ await capture(page, `{{projectName}}-${frontend.target}-login.png`, testInfo.outputPath(`${frontend.target}-login.png`))
297
+ await login(page, 'admin', adminPassword, true)
298
+ await expect(page.getByTestId('guide-offer')).toBeVisible()
299
+ await page.getByTestId('guide-start').click()
300
+ const popover = page.locator('.hai-guide-popover')
301
+ await expect(popover).toBeVisible()
302
+ await capture(page, `{{projectName}}-${frontend.target}-guide.png`, testInfo.outputPath(`${frontend.target}-guide.png`))
303
+ await page.keyboard.press('Escape')
304
+ await expect(popover).toHaveCount(0)
305
+ await page.getByTestId('admin-help').click()
306
+ await expect(page.getByTestId('help-center')).toBeVisible()
307
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
308
+ await capture(page, `{{projectName}}-${frontend.target}-help.png`, testInfo.outputPath(`${frontend.target}-help.png`))
309
+ await page.getByTestId('guide-article-profile').click()
310
+ await page.getByTestId('guide-article-start').click()
311
+ await expect(page.getByTestId('settings-page')).toBeVisible()
312
+ await expect(popover).toBeVisible()
313
+ await page.keyboard.press('Escape')
314
+ await expect(popover).toHaveCount(0)
315
+ await capture(page, `{{projectName}}-${frontend.target}-profile.png`, testInfo.outputPath(`${frontend.target}-profile.png`))
316
+ expect(pageErrors).toEqual([])
317
+ }
318
+ finally {
319
+ await page.close()
320
+ }
321
+ })
322
+ }
323
+
324
+ test('shared authentication supports public registration with configuration', async ({ page }) => {
325
+ test.setTimeout(90_000)
326
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR || path.resolve('test-results/auth-screenshots')
327
+ await mkdir(evidence, { recursive: true })
328
+ await page.goto('./')
329
+ await expect(page.getByTestId('login-form')).toBeVisible()
330
+ await page.screenshot({ path: path.join(evidence, '{{projectName}}-login.png'), fullPage: true })
331
+ await page.getByRole('button', { name: /立即注册|Register now/i }).click()
332
+ const registerForm = page.locator('.register-form')
333
+ await expect(registerForm.locator('#register-username')).toBeVisible()
334
+ await page.screenshot({ path: path.join(evidence, '{{projectName}}-register.png'), fullPage: true })
335
+ const username = `signup${randomUUID().replaceAll('-', '').slice(0, 12)}`
336
+ await registerForm.locator('#register-username').fill(username)
337
+ await registerForm.locator('#register-email').fill(`${username}@example.com`)
338
+ await registerForm.locator('#register-password').fill('SignupPassword123')
339
+ await registerForm.locator('#register-confirm-password').fill('SignupPassword123')
340
+ await registerForm.locator('button[type="submit"]').click()
341
+ await expect(page.getByTestId('settings-page')).toBeVisible()
342
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
343
+ if (!process.env.IAM_NOTIFICATION_URL)
344
+ return
345
+ const email = `${username}@example.com`
346
+ await page.getByTestId('logout').click()
347
+ await page.getByRole('button', { name: /邮件验证码|Email code/i, exact: true }).click()
348
+ await page.screenshot({ path: path.join(evidence, '{{projectName}}-email.png'), fullPage: true })
349
+ await page.locator('#login-username').fill(email)
350
+ await page.getByRole('button', { name: /发送验证码|Send code/i, exact: true }).click()
351
+ await expect(page.getByRole('button', { name: /秒后重发|Resend in/ })).toBeDisabled()
352
+ async function received(template: string) {
353
+ const response = await page.request.get(`${process.env.IAM_NOTIFICATION_URL}/messages?to=${encodeURIComponent(email)}`)
354
+ const messages = await response.json() as { template: string, vars: Record<string, string> }[]
355
+ return messages.findLast(item => item.template === template)?.vars
356
+ }
357
+ const otp = await received('otp_email')
358
+ expect(Boolean(otp?.code)).toBe(true)
359
+ await page.locator('#login-code').fill(otp?.code ?? '')
360
+ await page.locator('button[type="submit"]').click()
361
+ await expect(page.getByTestId('settings-page')).toBeVisible()
362
+ await page.getByTestId('logout').click()
363
+ await page.getByRole('button', { name: /忘记密码|Forgot password/i }).click()
364
+ await page.screenshot({ path: path.join(evidence, '{{projectName}}-forgot.png'), fullPage: true })
365
+ await page.locator('input[type="email"]').fill(email)
366
+ await page.locator('button[type="submit"]').click()
367
+ await expect(page.locator('.hai-toast-item[data-variant="success"]')).toBeVisible()
368
+ const reset = await received('password_reset')
369
+ expect(Boolean(reset?.token)).toBe(true)
370
+ await page.getByRole('button', { name: /已有重置令牌|I have a reset token/i }).click()
371
+ await page.screenshot({ path: path.join(evidence, '{{projectName}}-reset.png'), fullPage: true })
372
+ await page.goto(`./?token=${encodeURIComponent(reset?.token ?? '')}`)
373
+ await expect(page.locator('input[type="password"]').nth(0)).toBeVisible()
374
+ await page.locator('input[type="password"]').nth(0).fill('FullstackReset456')
375
+ await page.locator('input[type="password"]').nth(1).fill('FullstackReset456')
376
+ await page.locator('button[type="submit"]').click()
377
+ await expect(page.locator('.hai-toast-item[data-variant="success"]')).toBeVisible()
378
+ await page.getByRole('button', { name: /^(登录|Sign in|Login)$/i }).click()
379
+ await login(page, username, 'FullstackReset456')
380
+ await expect(page.getByTestId('settings-page')).toBeVisible()
381
+ expect(new URL(page.url()).searchParams.has('token')).toBe(false)
382
+ await page.reload()
383
+ await expect(page.getByTestId('settings-page')).toBeVisible()
384
+ })
385
+
386
+ test('frontend displays configured core metadata and restores the administrator session', async ({ page }, testInfo) => {
387
+ await page.goto('./')
388
+ await expect(page.getByTestId('app-name')).toHaveText(configuredName)
389
+ await expect(page).toHaveTitle(configuredName)
390
+ await expect(page.getByTestId('app-version')).toHaveText(`v${configuredVersion}`)
391
+ await expect(page.getByTestId('app-environment')).toHaveText(configuredEnv)
392
+ await login(page, 'admin', adminPassword)
393
+ await expect(page.getByTestId('users-page')).toBeVisible()
394
+ await page.reload()
395
+ await expect(page.getByTestId('users-page')).toBeVisible()
396
+ await expect(page.getByTestId('nav-roles')).toBeVisible()
397
+ await expect(page.getByTestId('nav-audit')).toHaveCount(0)
398
+ await expect(page.getByTestId('nav-configuration')).toHaveCount(0)
399
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
400
+ if (evidence)
401
+ await mkdir(evidence, { recursive: true })
402
+ await page.screenshot({ path: evidence ? path.join(evidence, '{{projectName}}-users.png') : testInfo.outputPath('users.png'), fullPage: true })
403
+ await page.getByTestId('logout').click()
404
+ await expect(page.getByTestId('login-form')).toBeVisible()
405
+ })
406
+
407
+ test('auditor reauthentication produces a verifiable private archive', async ({ page }, testInfo) => {
408
+ await page.goto('./')
409
+ await login(page, 'audit_admin', auditAdminPassword)
410
+ await expect(page.getByTestId('nav-users')).toHaveCount(0)
411
+ await expect(page.getByTestId('nav-configuration')).toHaveCount(0)
412
+ await page.getByTestId('nav-audit').click()
413
+ await page.getByTestId('audit-backup').click()
414
+ const confirmation = page.getByTestId('reauthentication-form')
415
+ await expect(confirmation).toBeVisible()
416
+ await capture(page, '{{projectName}}-reauthentication-dialog.png', testInfo.outputPath('reauthentication-dialog.png'))
417
+ await confirmation.getByRole('button', { name: /取消|Cancel|キャンセル/ }).click()
418
+ await expect(confirmation).not.toBeVisible()
419
+ await expect(page.getByTestId('audit-backup-receipt')).not.toBeVisible()
420
+ await dismissToasts(page)
421
+ await page.getByTestId('audit-backup').click()
422
+ await page.getByTestId('reauthentication-password').locator('input').fill('Incorrect-test-password!')
423
+ await confirmation.locator('button[type=submit]').click()
424
+ await expect(page.locator('.hai-toast-item[data-variant=error]')).toBeVisible()
425
+ await expect(page.locator('.hai-toast-item[data-variant=error]')).toContainText(/用户名或密码不正确|Username or password is incorrect|ユーザー名またはパスワード/)
426
+ await expect(confirmation.locator('.alert-error')).toHaveCount(0)
427
+ await expect(page.locator('.hai-toast-item[data-variant=error]')).toBeInViewport({ ratio: 1 })
428
+ await expect(page.getByTestId('audit-backup-receipt')).not.toBeVisible()
429
+ await capture(page, '{{projectName}}-reauthentication-error.png', testInfo.outputPath('reauthentication-error.png'))
430
+ await page.setViewportSize({ width: 390, height: 960 })
431
+ await expect(page.locator('.hai-toast-item[data-variant=error]')).toBeInViewport({ ratio: 1 })
432
+ await capture(page, '{{projectName}}-reauthentication-error-390.png', testInfo.outputPath('reauthentication-error-390.png'))
433
+ await dismissToasts(page)
434
+ await page.setViewportSize({ width: 1440, height: 960 })
435
+ await page.getByTestId('reauthentication-password').locator('input').fill(auditAdminPassword)
436
+ await confirmation.locator('button[type=submit]').click()
437
+ const receipt = page.getByTestId('audit-backup-receipt')
438
+ await expect(receipt).toBeVisible()
439
+ await expect(receipt).toContainText(/SHA-256: [a-f0-9]{64}/)
440
+ await capture(page, '{{projectName}}-audit-backup-receipt.png', testInfo.outputPath('audit-backup-receipt.png'))
441
+ const key = (await receipt.textContent())?.match(/hai-private\/audit-backups\/[\w-]+\.jsonl/)?.[0]
442
+ expect(key).toBeTruthy()
443
+ const exposed = await page.request.get(`${apiBaseUrl}${apiPrefix}/assets/${key}`)
444
+ expect(exposed.status()).toBe(404)
445
+ })
446
+
447
+ test('one-time invitation completes password setup through the configured notification', async ({ page, browser }) => {
448
+ test.skip(!process.env.IAM_NOTIFICATION_URL, 'Requires the owned notification fixture')
449
+ test.setTimeout(90000)
450
+ const username = `invited-${randomUUID().slice(0, 8)}`
451
+ const email = `${username}@example.test`
452
+ await page.goto('./')
453
+ await login(page, 'admin', adminPassword)
454
+ await page.getByTestId('invite-user').click()
455
+ await page.getByTestId('user-username').locator('input').fill(username)
456
+ await page.getByTestId('user-email').locator('input').fill(email)
457
+ await page.getByTestId('user-save').click()
458
+ const confirmation = page.getByTestId('reauthentication-form')
459
+ await expect(confirmation).toBeVisible()
460
+ await page.getByTestId('reauthentication-password').locator('input').fill(adminPassword)
461
+ await confirmation.locator('button[type=submit]').click()
462
+ await expect(page.getByTestId('user-editor')).not.toBeVisible()
463
+ const notified = await page.request.get(`${process.env.IAM_NOTIFICATION_URL}/messages?to=${encodeURIComponent(email)}`)
464
+ const messages = await notified.json() as { template: string, vars: Record<string, string> }[]
465
+ const token = messages.findLast(item => item.template === 'password_reset')?.vars.token
466
+ expect(token).toBeTruthy()
467
+ const context = await browser.newContext()
468
+ try {
469
+ const receiver = await context.newPage()
470
+ await receiver.goto(`./?token=${encodeURIComponent(token ?? '')}`)
471
+ const passwords = receiver.locator('input[type=password]')
472
+ await passwords.nth(0).fill('Invited-Setup-123!')
473
+ await passwords.nth(1).fill('Invited-Setup-123!')
474
+ await receiver.locator('button[type=submit]').click()
475
+ await expect(receiver.locator('.hai-toast-item[data-variant="success"]')).toBeVisible()
476
+ await receiver.getByRole('button', { name: /^(登录|Sign in|Login)$/i }).click()
477
+ await login(receiver, username, 'Invited-Setup-123!')
478
+ await expect(receiver.getByTestId('settings-page')).toBeVisible()
479
+ await expect(receiver.getByTestId('nav-users')).toHaveCount(0)
480
+ }
481
+ finally { await context.close() }
482
+ })
483
+
484
+ test('business configuration is isolated and the public notice reaches other duties', async ({ page }) => {
485
+ await page.goto('./')
486
+ await login(page, 'config_admin', configAdminPassword)
487
+ await expect(page.getByTestId('nav-users')).toHaveCount(0)
488
+ await expect(page.getByTestId('nav-audit')).toHaveCount(0)
489
+ await page.getByTestId('nav-configuration').click()
490
+ await expect(page.getByTestId('configuration-page')).toBeVisible()
491
+ await page.getByTestId('business-notice-input').locator('textarea').fill('Configured business notice')
492
+ await page.getByTestId('configuration-page').getByRole('button', { name: /保存|Save/, exact: true }).click()
493
+ await expect(page.getByTestId('business-notice')).toHaveText('Configured business notice')
494
+ await page.getByTestId('logout').click()
495
+ await login(page, 'admin', adminPassword)
496
+ await expect(page.getByTestId('nav-configuration')).toHaveCount(0)
497
+ await expect(page.getByTestId('business-notice')).toHaveText('Configured business notice')
498
+ })
499
+
500
+ test('audit filters collapse without losing values and fit compact workspaces', async ({ page }, testInfo) => {
501
+ await page.goto('./')
502
+ await login(page, 'audit_admin', auditAdminPassword)
503
+ await page.getByTestId('nav-audit').click()
504
+ await expect(page.getByTestId('audit-page')).toBeVisible()
505
+ const timeHeader = page.getByTestId('audit-page').locator('th').first()
506
+ await timeHeader.getByRole('button').click()
507
+ await expect(timeHeader).toHaveAttribute('aria-sort', 'ascending')
508
+ const filters = page.getByTestId('audit-filters')
509
+ const toggle = filters.locator('button[aria-controls]')
510
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false')
511
+ await expect(page.getByTestId('audit-resource')).toBeHidden()
512
+ await toggle.click()
513
+ const resource = page.getByTestId('audit-resource').locator('input')
514
+ await resource.fill('retained-filter')
515
+ await toggle.click()
516
+ await expect(resource).toBeHidden()
517
+ await toggle.press('Space')
518
+ await expect(resource).toHaveValue('retained-filter')
519
+ await expect(page.locator('header h1')).toHaveCount(1)
520
+ await expect(page.getByTestId('audit-page').locator('h1')).toHaveCount(0)
521
+ for (const width of [1440, 1920, 2560, 3840, 960, 390, 320]) {
522
+ await page.setViewportSize({ width, height: 900 })
523
+ if (width < 1024)
524
+ await expect.poll(() => page.locator('aside').evaluate(sidebar => sidebar.getBoundingClientRect().right)).toBeLessThanOrEqual(1)
525
+ const controls = await page.getByTestId('audit-advanced').evaluate(panel => [...panel.querySelectorAll('input')].map((element) => {
526
+ // 复合控件的点击框包含边框和留白,内部文字输入区不等于完整控件高度。
527
+ const control = element.closest('.fieldset') ? element.parentElement! : element
528
+ const rect = control.getBoundingClientRect()
529
+ return { left: rect.left, right: rect.right, height: rect.height }
530
+ }))
531
+ expect(controls.length).toBeGreaterThan(2)
532
+ for (const control of controls) {
533
+ expect(control.height).toBeGreaterThanOrEqual(28)
534
+ expect(control.left).toBeGreaterThanOrEqual(0)
535
+ expect(control.right).toBeLessThanOrEqual(width)
536
+ }
537
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
538
+ if (width === 1440 || width === 390) {
539
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
540
+ if (evidence)
541
+ await mkdir(evidence, { recursive: true })
542
+ const file = `{{projectName}}-audit-${width}.png`
543
+ await page.screenshot({ path: evidence ? path.join(evidence, file) : testInfo.outputPath(file), fullPage: true, animations: 'disabled' })
544
+ }
545
+ }
546
+ const reset = page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/admin/audit/list'))
547
+ await filters.getByRole('button', { name: /重置|Reset|リセット/, exact: true }).click()
548
+ await reset
549
+ await expect(resource).toHaveValue('')
550
+ await page.getByTestId('audit-category').getByRole('combobox').click()
551
+ await page.getByRole('option', { name: /系统|System|システム/ }).click()
552
+ const filtered = page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/admin/audit/list')).then(async (response) => {
553
+ expect(response.status()).toBe(200)
554
+ await response.body()
555
+ })
556
+ await page.getByTestId('audit-action').locator('input').press('Enter')
557
+ await filtered
558
+ await expect(page.getByTestId('audit-page').getByRole('cell', { name: 'system', exact: true }).first()).toBeVisible()
559
+ const categories = await page.getByTestId('audit-page').locator('tbody tr').evaluateAll(rows => rows.map(row => row.querySelectorAll('td')[3]?.textContent?.trim()))
560
+ expect(new Set(categories)).toEqual(new Set(['system']))
561
+ await toggle.click()
562
+ await page.getByTestId('audit-page').getByRole('button', { name: /详情|Details/ }).first().click()
563
+ await expect(page.getByRole('dialog')).toBeVisible()
564
+ await page.screenshot({ path: testInfo.outputPath('audit-short-table-details.png'), fullPage: true, animations: 'disabled' })
565
+ })
566
+
567
+ test('administrator manages every duty through one lifecycle and the shared password reset form', async ({ page, browser }, testInfo) => {
568
+ test.setTimeout(120_000)
569
+ const username = `duty-${randomUUID().slice(0, 8)}`
570
+ const password = 'Duty-test-Pass123!'
571
+ const newPassword = 'Reset-test-Pass456!'
572
+ await page.goto('./')
573
+ await login(page, 'admin', adminPassword)
574
+ await page.getByTestId('create-user').click()
575
+ await page.getByTestId('user-role').click()
576
+ const roleOptions = page.getByRole('option')
577
+ await expect(roleOptions).toHaveCount(5)
578
+ await expect(roleOptions.filter({ hasText: /系统管理员|System administrator/ })).toHaveCount(1)
579
+ await expect(roleOptions.filter({ hasText: /审计管理员|Audit administrator/ })).toHaveCount(1)
580
+ await expect(roleOptions.filter({ hasText: /配置管理员|Configuration administrator/ })).toHaveCount(1)
581
+ await capture(page, 'account-role-choices.png', testInfo.outputPath('account-role-choices.png'))
582
+ await roleOptions.filter({ hasText: /审计管理员|Audit administrator/ }).click()
583
+ await page.getByTestId('user-username').locator('input').fill(username)
584
+ await page.getByTestId('user-display-name').locator('input').fill('Account policy example')
585
+ await page.getByTestId('user-password').locator('input').fill(password)
586
+ await page.getByTestId('user-save').click()
587
+ await expect(page.getByTestId('user-editor')).not.toBeVisible()
588
+ await userEditor(page, username)
589
+ await page.getByTestId('user-role').click()
590
+ await page.getByRole('option', { name: /配置管理员|Configuration administrator/ }).click()
591
+ await page.getByTestId('account-state').click()
592
+ await expect(page.getByRole('option')).toHaveCount(3)
593
+ await capture(page, 'account-lifecycle-options.png', testInfo.outputPath('account-lifecycle-options.png'))
594
+ await page.getByRole('option', { name: /^(休眠|Dormant|休止)$/ }).click()
595
+ await page.getByTestId('user-save').click()
596
+ await expect(page.getByTestId('user-editor')).not.toBeVisible()
597
+ const row = page.getByRole('row').filter({ hasText: username })
598
+ await expect(row).toContainText(/休眠|Dormant/)
599
+ await dismissToasts(page)
600
+ await row.getByTestId('reset-user-password').click()
601
+ const dialog = page.getByTestId('password-reset-dialog')
602
+ await expect(dialog).toBeVisible()
603
+ await expect(dialog.locator('#old-password')).toHaveCount(0)
604
+ await capture(page, 'account-password-reset.png', testInfo.outputPath('account-password-reset.png'))
605
+ await dialog.locator('#new-password').fill(newPassword)
606
+ await dialog.locator('#confirm-password').fill(`${newPassword}different`)
607
+ await expect(page.locator('.hai-toast-item[data-variant=error]')).toContainText(/不一致|match|一致/)
608
+ await expect(dialog.locator('button[type=submit]')).toBeDisabled()
609
+ await dialog.locator('#confirm-password').fill(newPassword)
610
+ await dismissToasts(page)
611
+ await dialog.locator('button[type=submit]').click()
612
+ await expect(dialog).not.toBeVisible()
613
+ await expect(row).toContainText(/休眠|Dormant/)
614
+ await expect(page.getByTestId('notice')).toContainText(/密码已重置|Password reset/)
615
+ await capture(page, 'account-password-reset-success.png', testInfo.outputPath('account-password-reset-success.png'))
616
+
617
+ const context = await browser.newContext()
618
+ const target = await context.newPage()
619
+ try {
620
+ await target.goto('./')
621
+ await submitLogin(target, username, newPassword)
622
+ const accountError = target.locator('.hai-toast-item[data-variant=error]')
623
+ // 公开登录不透露休眠状态;管理员在账号列表查看具体状态。
624
+ await expect(accountError).toContainText(/用户名或密码不正确|Username or password is incorrect|ユーザー名またはパスワード/i)
625
+ await expect(target.getByTestId('login-form')).toBeVisible()
626
+ await userEditor(page, username)
627
+ await page.getByTestId('account-state').click()
628
+ await page.getByRole('option', { name: /^(激活|Active|有効)$/ }).click()
629
+ await page.getByTestId('user-save').click()
630
+ await expect(page.getByTestId('user-editor')).not.toBeVisible()
631
+ await submitLogin(target, username, password)
632
+ await expect(target.locator('.hai-toast-item[data-variant=error]')).toContainText(/不正确|incorrect/i)
633
+ await login(target, username, newPassword)
634
+ await expect(target.getByTestId('nav-configuration')).toBeVisible()
635
+ await expect(target.getByTestId('nav-users')).toHaveCount(0)
636
+ await expect(target.getByTestId('nav-audit')).toHaveCount(0)
637
+ }
638
+ finally { await context.close() }
639
+
640
+ await page.setViewportSize({ width: 390, height: 844 })
641
+ await expect(page.getByTestId('user-cards')).toBeVisible()
642
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
643
+ await capture(page, 'account-lifecycle-mobile.png', testInfo.outputPath('account-lifecycle-mobile.png'))
644
+ await page.setViewportSize({ width: 1440, height: 960 })
645
+ await row.getByRole('button', { name: /删除|Delete/, exact: true }).click()
646
+ await page.getByTestId('confirm-delete-user').click()
647
+ await expect(row).toHaveCount(0)
648
+ })
649
+
650
+ test('administrator and user complete account, avatar, preference, password and audit workflows', async ({ page, browser }) => {
651
+ test.setTimeout(120_000)
652
+ const username = `member-${randomUUID().slice(0, 8)}`
653
+ const password = 'User-test-Pass123!'
654
+ const newPassword = 'Changed-test-Pass123!'
655
+ const encryptedRequests: string[] = []
656
+ const encryptedResponses: string[] = []
657
+ page.on('request', (request) => {
658
+ if (request.url().includes(`${apiPrefix}/`) && !request.url().includes('/_hai/') && request.postData()) {
659
+ expect(request.headers()['x-encrypted']).toBe('true')
660
+ expect(request.postData()).not.toContain('"password"')
661
+ expect(request.postData()).not.toContain(adminPassword)
662
+ encryptedRequests.push(request.url())
663
+ }
664
+ })
665
+ page.on('response', (response) => {
666
+ if (response.request().method() !== 'OPTIONS' && response.url().includes(`${apiPrefix}/`) && !response.url().includes('/_hai/') && !response.url().includes('/assets/')) {
667
+ expect(response.headers()['x-encrypted']).toBe('true')
668
+ encryptedResponses.push(response.url())
669
+ }
670
+ })
671
+ await page.goto('./')
672
+ await login(page, 'admin', adminPassword)
673
+ await expect(page.getByTestId('users-page')).toBeVisible()
674
+ await page.getByTestId('create-user').click()
675
+ await expect(page.getByRole('dialog', { name: /新增用户|New user|Create user|Add user/ })).toBeVisible()
676
+ await expect(page.getByTestId('user-username').locator('input')).toBeFocused()
677
+ await page.getByTestId('user-username').locator('input').fill(username)
678
+ await page.getByTestId('user-display-name').locator('input').fill('Test member')
679
+ await page.getByTestId('user-email').locator('input').fill(`${username}@example.com`)
680
+ await page.getByTestId('user-password').locator('input').fill(password)
681
+ await page.getByTestId('user-save').click()
682
+ await expect(page.getByTestId('user-editor')).not.toBeVisible()
683
+ const userRow = page.getByRole('row').filter({ hasText: username })
684
+ await expect(userRow).toBeVisible()
685
+ await userRow.getByTestId('user-avatar-input').setInputFiles({ name: 'avatar.png', mimeType: 'image/png', buffer: avatar })
686
+ await expect(userRow.locator('img')).toHaveAttribute('src', /\/assets\/avatars\//)
687
+ await expect.poll(() => userRow.locator('img').evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
688
+ await page.getByTestId('nav-roles').click()
689
+ await expect(page.getByTestId('roles-page')).toBeVisible()
690
+ await page.getByTestId('roles-page').getByRole('button', { name: /编辑说明|Edit description/ }).first().click()
691
+ await page.getByTestId('role-description').locator('textarea').fill('Workspace administration')
692
+ await page.getByRole('dialog').getByRole('button', { name: /保存|Save/, exact: true }).focus()
693
+ await page.keyboard.press('Tab')
694
+ await expect(page.getByRole('dialog').getByRole('button', { name: /关闭|Close/, exact: true })).toBeFocused()
695
+ await page.keyboard.press('Shift+Tab')
696
+ await expect(page.getByRole('dialog').getByRole('button', { name: /保存|Save/, exact: true })).toBeFocused()
697
+ await page.getByRole('dialog').getByRole('button', { name: /保存|Save/, exact: true }).click()
698
+ await expect(page.getByTestId('roles-page')).toContainText('Workspace administration')
699
+
700
+ const userContext = await browser.newContext()
701
+ const userPage = await userContext.newPage()
702
+ try {
703
+ await userPage.goto('./')
704
+ await login(userPage, username, password)
705
+ await expect(userPage.getByTestId('settings-page')).toBeVisible()
706
+ await expect(userPage.getByTestId('nav-users')).toHaveCount(0)
707
+ await expect(userPage.getByTestId('nav-roles')).toHaveCount(0)
708
+ await expect(userPage.getByTestId('nav-audit')).toHaveCount(0)
709
+ await userPage.getByTestId('profile-form').locator('input[name="displayName"]').fill('Updated member')
710
+ await userPage.getByTestId('profile-form').locator('input[name="email"]').fill('')
711
+ await userPage.getByTestId('profile-form').getByRole('button', { name: /保存|Save/, exact: true }).click()
712
+ await expect(userPage.getByTestId('notice')).toBeVisible()
713
+ await userPage.getByTestId('profile-form').locator('input[type="file"]').setInputFiles({ name: 'personal.png', mimeType: 'image/png', buffer: avatar })
714
+ await expect(userPage.getByTestId('notice')).toHaveText(/头像已更新|Avatar updated/)
715
+ await userPage.getByTestId('profile-language').click()
716
+ await userPage.getByRole('option', { name: 'English', exact: true }).click()
717
+ await expect(userPage.getByTestId('nav-settings')).toHaveText('Personal settings')
718
+ await expect(userPage.locator('html')).toHaveAttribute('lang', 'en-US')
719
+ const themes = [{{#each fullstack.theme.themes}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}]
720
+ const selectedTheme = themes.includes('formal') ? 'formal' : themes.includes('high-contrast') ? 'high-contrast' : themes.find(theme => theme !== '{{fullstack.theme.defaultTheme}}') ?? themes[0]
721
+ await userPage.getByTestId('profile-theme').click()
722
+ await userPage.getByRole('option').nth(themes.indexOf(selectedTheme)).click()
723
+ await expect(userPage.locator('html')).toHaveAttribute('data-theme', selectedTheme)
724
+ if (selectedTheme === 'high-contrast')
725
+ await expect.poll(() => userPage.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe('dark')
726
+ await userPage.reload()
727
+ await expect(userPage.getByTestId('settings-page')).toBeVisible()
728
+ await expect(userPage.getByTestId('nav-settings')).toHaveText('Personal settings')
729
+ await expect(userPage.locator('html')).toHaveAttribute('data-theme', selectedTheme)
730
+ await expect(userPage.getByTestId('profile-form').locator('input[name="displayName"]')).toHaveValue('Updated member')
731
+ await expect(userPage.getByTestId('profile-form').locator('input[name="email"]')).toHaveValue('')
732
+ await expect.poll(() => userPage.getByTestId('profile-form').locator('img').evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
733
+ await userPage.getByTestId('profile-language').click()
734
+ await userPage.getByRole('option', { name: '日本語', exact: true }).click()
735
+ await expect(userPage.getByTestId('nav-settings')).toHaveText('個人設定')
736
+ await expect(userPage.locator('html')).toHaveAttribute('lang', 'ja-JP')
737
+ await userPage.reload()
738
+ await expect(userPage.getByTestId('nav-settings')).toHaveText('個人設定')
739
+ await expect(userPage.locator('html')).toHaveAttribute('lang', 'ja-JP')
740
+ await userPage.getByTestId('profile-language').click()
741
+ await userPage.getByRole('option', { name: 'English', exact: true }).click()
742
+ await expect(userPage.getByTestId('nav-settings')).toHaveText('Personal settings')
743
+ await userPage.setViewportSize({ width: 390, height: 844 })
744
+ await expect.poll(() => userPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
745
+ await userPage.getByTestId('mobile-menu').click()
746
+ await expect(userPage.getByTestId('nav-settings')).toBeVisible()
747
+ await userPage.getByTestId('nav-settings').click()
748
+ const passwordInputs = userPage.getByTestId('password-form').locator('input[type="password"]')
749
+ await passwordInputs.nth(0).fill(password)
750
+ await passwordInputs.nth(1).fill(password)
751
+ await passwordInputs.nth(2).fill(password)
752
+ await dismissToasts(userPage)
753
+ await userPage.getByTestId('password-form').getByRole('button', { name: /Change password/i }).click()
754
+ await expect(userPage.getByTestId('settings-page')).toBeVisible()
755
+ await expect(userPage.locator('.hai-toast-item[data-variant=error]')).toContainText(/相同|different from|異なる/i)
756
+ await dismissToasts(userPage)
757
+ await passwordInputs.nth(1).fill(newPassword)
758
+ await passwordInputs.nth(2).fill(newPassword)
759
+ await userPage.getByTestId('password-form').getByRole('button', { name: /Change password/i }).click()
760
+ await expect(userPage.getByTestId('login-form')).toBeVisible()
761
+ await submitLogin(userPage, username, password)
762
+ await expect(userPage.locator('.hai-toast-item[data-variant=error]')).toContainText(/不正确|incorrect/i)
763
+ await login(userPage, username, newPassword)
764
+ await expect(userPage.getByTestId('settings-page')).toBeVisible()
765
+
766
+ await page.getByTestId('nav-users').click()
767
+ await userEditor(page, username)
768
+ await page.getByTestId('account-state').click()
769
+ await page.getByRole('option', { name: /^(休眠|Dormant|休止)$/ }).click()
770
+ await page.getByTestId('user-save').click()
771
+ await expect(page.getByTestId('user-editor')).not.toBeVisible()
772
+ await userPage.getByTestId('profile-form').getByRole('button', { name: 'Save', exact: true }).click()
773
+ const recovery = userPage.getByTestId('session-recovery-dialog')
774
+ await expect(recovery).toBeVisible()
775
+ // 休眠账号不能以正确密码恢复旧操作;原地登录不放松账号状态控制。
776
+ const recoveryLogin = recovery.locator('form.login-form')
777
+ await dismissToasts(userPage)
778
+ await recoveryLogin.locator('input[type=password]').fill(newPassword)
779
+ await recoveryLogin.locator('button[type=submit]').click()
780
+ await expect(userPage.locator('.hai-toast-item[data-variant=error]')).toBeVisible()
781
+ await expect(recovery).toBeVisible()
782
+ await recovery.getByRole('button', { name: /Sign in later|稍后登录/ }).click()
783
+ }
784
+ finally {
785
+ await userContext.close()
786
+ }
787
+
788
+ await page.getByRole('row').filter({ hasText: username }).getByRole('button', { name: /删除|Delete/, exact: true }).click()
789
+ await page.getByTestId('confirm-delete-user').click()
790
+ await expect(page.getByRole('row').filter({ hasText: username })).toHaveCount(0)
791
+ await page.getByTestId('logout').click()
792
+ await login(page, 'audit_admin', auditAdminPassword)
793
+ await page.getByTestId('nav-audit').click()
794
+ await page.getByTestId('audit-filters').locator('button[aria-controls]').click()
795
+ await page.getByTestId('audit-resource').locator('input').fill('users')
796
+ await page.getByTestId('audit-page').locator('form').getByRole('button', { name: /查询|Search/ }).click()
797
+ await expect(page.getByTestId('audit-page')).toContainText('delete')
798
+ await page.getByTestId('audit-page').getByRole('button', { name: /详情|Details/ }).first().click()
799
+ await expect(page.getByRole('dialog')).toContainText(/users/)
800
+ expect(encryptedRequests.length).toBeGreaterThan(5)
801
+ expect(encryptedResponses.length).toBeGreaterThan(5)
802
+ })
803
+ {{/if}}
804
+ {{#if features.feedback}}
805
+ {{#if fullstack.e2eFrontend}}
806
+
807
+ test('feedback submits an empty description and business operator can process and reopen it', async ({ page }, testInfo) => {
808
+ await page.goto('./')
809
+ await login(page, 'business_operator', businessOperatorPassword)
810
+ const feedbackImage = Buffer.from(await page.evaluate(() => {
811
+ const canvas = document.createElement('canvas')
812
+ canvas.width = canvas.height = 64
813
+ const context = canvas.getContext('2d')!
814
+ context.fillStyle = '#ef4444'
815
+ context.fillRect(0, 0, 64, 64)
816
+ return canvas.toDataURL('image/png').split(',')[1]!
817
+ }), 'base64')
818
+ // 大于框架默认 1 MiB 的真实日志必须经加密 HTTP 上传,并从服务端问题包逐字节取回。
819
+ const feedbackLog = Buffer.from('INFO feedback diagnostic: operation completed successfully\n'.repeat(36 * 1024))
820
+ expect(feedbackLog.byteLength).toBeGreaterThan(1024 * 1024)
821
+ expect(feedbackLog.byteLength).toBeLessThan(5 * 1024 * 1024)
822
+ await page.getByTestId('feedback-open').click()
823
+ const dialog = page.getByRole('dialog')
824
+ const desktopViewport = page.viewportSize()
825
+ if (!desktopViewport)
826
+ throw new Error('Feedback browser viewport is missing')
827
+ async function expectUncovered(target: typeof dialog, fractions = [0.1, 0.5, 0.9]) {
828
+ await expect.poll(() => target.evaluate((element, samples) => {
829
+ const rect = element.getBoundingClientRect()
830
+ return samples.every(x => samples.every((y) => {
831
+ const top = document.elementFromPoint(rect.left + rect.width * x, rect.top + rect.height * y)
832
+ return top !== null && (top === element || element.contains(top))
833
+ }))
834
+ }, fractions), { message: 'Feedback content must not be covered by the background header or table' }).toBe(true)
835
+ }
836
+ async function expectDialogViewport() {
837
+ await expect.poll(() => dialog.evaluate((element) => {
838
+ const rect = element.getBoundingClientRect()
839
+ return rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight
840
+ }), { message: 'The entire feedback dialog must stay inside the viewport' }).toBe(true)
841
+ await expectUncovered(dialog.getByRole('heading').first())
842
+ // 关闭按钮为圆形,采样点须位于圆内;标题与图片仍检查原有九点范围。
843
+ await expectUncovered(dialog.getByRole('button', { name: /关闭|Close|閉じる/ }).first(), [0.25, 0.5, 0.75])
844
+ }
845
+ await expectDialogViewport()
846
+ await capture(page, 'feedback-collect-desktop.png', testInfo.outputPath('feedback-collect-desktop.png'))
847
+ await page.setViewportSize({ width: 390, height: 844 })
848
+ await expectDialogViewport()
849
+ await capture(page, 'feedback-collect-mobile.png', testInfo.outputPath('feedback-collect-mobile.png'))
850
+ await page.setViewportSize(desktopViewport)
851
+ await expectDialogViewport()
852
+ await dialog.locator('summary').filter({ hasText: /更多选项|More options|その他のオプション/ }).click()
853
+ await dialog.locator('input[type=file]').setInputFiles([
854
+ { name: 'attached-preview.png', mimeType: 'image/png', buffer: feedbackImage },
855
+ { name: 'attached-large-log.txt', mimeType: 'text/plain', buffer: feedbackLog },
856
+ { name: 'discarded.txt', mimeType: 'text/plain', buffer: Buffer.from('discarded local material') },
857
+ ])
858
+ const uploaded = dialog.locator('img[alt="attached-preview.png"]')
859
+ await expect(uploaded).toBeVisible()
860
+ await uploaded.scrollIntoViewIfNeeded()
861
+ await expectDialogViewport()
862
+ await expectUncovered(uploaded)
863
+ await capture(page, 'feedback-image-before-mask.png', testInfo.outputPath('feedback-image-before-mask.png'))
864
+ await uploaded.screenshot({ path: testInfo.outputPath('feedback-image-before-mask-detail.png'), animations: 'disabled' })
865
+ const originalPreview = await uploaded.getAttribute('src')
866
+ await dialog.getByRole('button', { name: /遮挡敏感区域|Mask sensitive area|機密情報を隠す/ }).last().click()
867
+ await dialog.getByRole('button', { name: /应用标注|Apply annotation|注釈を適用/ }).last().click()
868
+ await expect(uploaded).not.toHaveAttribute('src', originalPreview ?? '')
869
+ // 读取烧录后的实际像素与字节,后续核对服务端 ZIP 携带的是同一张脱敏图片。
870
+ const maskedPreview = await uploaded.evaluate(async (image: HTMLImageElement) => {
871
+ await image.decode()
872
+ const canvas = document.createElement('canvas')
873
+ canvas.width = image.naturalWidth
874
+ canvas.height = image.naturalHeight
875
+ const context = canvas.getContext('2d')!
876
+ context.drawImage(image, 0, 0)
877
+ return {
878
+ pixel: Array.from(context.getImageData(12, 12, 1, 1).data),
879
+ bytes: Array.from(new Uint8Array(await (await fetch(image.src)).arrayBuffer())),
880
+ }
881
+ })
882
+ expect(maskedPreview.pixel).toEqual([17, 17, 17, 255])
883
+ await dialog.getByRole('button', { name: /^(删除|Remove|削除) discarded.txt$/ }).click()
884
+ await expect(dialog.getByText('discarded.txt', { exact: true })).toHaveCount(0)
885
+ await uploaded.scrollIntoViewIfNeeded()
886
+ await expectDialogViewport()
887
+ await expectUncovered(uploaded)
888
+ await capture(page, 'feedback-preview.png', testInfo.outputPath('feedback-preview.png'))
889
+ await uploaded.screenshot({ path: testInfo.outputPath('feedback-image-after-mask-detail.png'), animations: 'disabled' })
890
+ await dialog.getByRole('button', { name: /提交反馈|Submit Feedback|フィードバックを送信/ }).click()
891
+ await expect(dialog.getByTestId('feedback-receipt')).toContainText(/FB-/)
892
+ const number = (await dialog.getByTestId('feedback-receipt').textContent())?.match(/FB-[A-Za-z0-9-]+/)?.[0]
893
+ expect(number).toBeTruthy()
894
+ if (!number)
895
+ throw new Error('Feedback receipt number is missing')
896
+ await expectDialogViewport()
897
+ await capture(page, 'feedback-accepted.png', testInfo.outputPath('feedback-accepted.png'))
898
+ await dialog.getByRole('button', { name: /关闭|Close|閉じる/ }).filter({ hasText: /关闭|Close|閉じる/ }).click()
899
+ await page.getByTestId('nav-feedback').click()
900
+ await expect(page.getByTestId('feedback-page')).toBeVisible()
901
+ const row = page.getByRole('row').filter({ hasText: number })
902
+ await expect(row).toBeVisible()
903
+ await row.getByRole('button', { name: /标记已处理|Mark handled|処理済み/ }).click()
904
+ await expect(row).toContainText(/已处理|Handled|処理済み/)
905
+ await row.getByRole('button', { name: /撤销处理|Reopen|処理を取り消す/ }).scrollIntoViewIfNeeded()
906
+ await capture(page, 'feedback-handled.png', testInfo.outputPath('feedback-handled.png'))
907
+ await row.getByRole('button', { name: /撤销处理|Reopen|処理を取り消す/ }).click()
908
+ await expect(row).toContainText(/未处理|Pending|未処理/)
909
+ await capture(page, 'feedback-list.png', testInfo.outputPath('feedback-list.png'))
910
+ const feedbackList = page.locator('.feedback-list')
911
+ await expect(feedbackList.locator('.hai-filter-bar')).toBeVisible()
912
+ await expect(feedbackList.locator('button[type="submit"]')).toBeVisible()
913
+ const submittedHeader = feedbackList.getByRole('columnheader', { name: /提交时间|Submitted|投稿日時/ })
914
+ await submittedHeader.getByRole('button').click()
915
+ await expect(submittedHeader).toHaveAttribute('aria-sort', 'ascending')
916
+ await feedbackList.locator('button[aria-controls]').click()
917
+ for (const width of [390, 1440, 1920, 2560, 3840]) {
918
+ await page.setViewportSize({ width, height: 900 })
919
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
920
+ await expect(feedbackList.locator('.hai-pagination__navigation')).toBeVisible()
921
+ await feedbackList.locator('table').evaluate((table) => {
922
+ table.parentElement!.scrollLeft = 0
923
+ })
924
+ await capture(page, `feedback-unified-${width}.png`, testInfo.outputPath(`feedback-unified-${width}.png`))
925
+ }
926
+ await page.setViewportSize({ width: 1440, height: 1000 })
927
+
928
+ const downloadEvent = page.waitForEvent('download')
929
+ await row.getByRole('button', { name: /导出问题包|Export diagnostic bundle|診断パッケージをエクスポート/ }).click()
930
+ const download = await downloadEvent
931
+ expect(download.suggestedFilename()).toBe(`${number}.zip`)
932
+ const archivePath = testInfo.outputPath('feedback-export.zip')
933
+ await download.saveAs(archivePath)
934
+ expect(await download.failure()).toBeNull()
935
+ // 复用公开读取器校验路径、容量和全部文件摘要,不以下载事件代替包内容验收。
936
+ const opened = await readFeedbackBundle(new Uint8Array(await readFile(archivePath)))
937
+ expect(opened.success).toBe(true)
938
+ if (!opened.success)
939
+ throw new Error(opened.error.message)
940
+ const bundle = opened.data
941
+ // 对照各自产物旁证、各自 manifest 和本工程 Git;不能用服务端展示配置冒充浏览器版本。
942
+ const browserMetadata: { appVersion: string, buildId: string, commit: string } = JSON.parse(await readFile(path.resolve('apps/{{projectName}}-{{fullstack.e2eFrontend.target}}/dist/build-metadata.json'), 'utf8'))
943
+ const serverMetadata: { appVersion: string, buildId: string, commit: string } = JSON.parse(await readFile(path.resolve('packages/{{projectName}}-serv/dist/build-metadata.json'), 'utf8'))
944
+ const browserManifest: { version: string } = JSON.parse(await readFile(path.resolve('apps/{{projectName}}-{{fullstack.e2eFrontend.target}}/package.json'), 'utf8'))
945
+ const serverManifest: { version: string } = JSON.parse(await readFile(path.resolve('packages/{{projectName}}-serv/package.json'), 'utf8'))
946
+ let expectedCommit = 'unknown'
947
+ try {
948
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', 'package.json'], { stdio: 'pipe', windowsHide: true })
949
+ expectedCommit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', stdio: 'pipe', windowsHide: true }).trim()
950
+ }
951
+ catch { /* 无 Git 的源码归档必须明确保留 unknown,不冒用父工程提交。 */ }
952
+ if (process.env.E2E_BUILD_COMMIT) {
953
+ expect(expectedCommit).toBe(process.env.E2E_BUILD_COMMIT)
954
+ expect(expectedCommit).toMatch(/^(?:[a-f\d]{40}|[a-f\d]{64})$/i)
955
+ expect(browserManifest.version).not.toBe(serverManifest.version)
956
+ }
957
+ for (const [metadata, version] of [[browserMetadata, browserManifest.version], [serverMetadata, serverManifest.version]] as const) {
958
+ expect(Object.keys(metadata).sort()).toEqual(['appVersion', 'buildId', 'commit'])
959
+ expect(metadata.appVersion).toBe(version)
960
+ expect(metadata.buildId).toMatch(/^[\w.+-]{1,160}$/)
961
+ expect(metadata.buildId).not.toBe('unknown')
962
+ expect(metadata.commit).toBe(expectedCommit)
963
+ }
964
+ const browserEnvironment = bundle.evidence.find(item => item.kind === 'environment' && item.source.startsWith('browser:'))
965
+ const serverEnvironment = bundle.evidence.find(item => item.kind === 'environment' && item.source.startsWith('server:'))
966
+ expect(browserEnvironment?.data).toMatchObject(browserMetadata)
967
+ expect(serverEnvironment?.data.service).toMatchObject(serverMetadata)
968
+ expect(bundle.report).toMatchObject(browserMetadata)
969
+ await testInfo.attach('feedback-build-metadata', { body: JSON.stringify({ browser: browserMetadata, server: serverMetadata }), contentType: 'application/json' })
970
+ expect(bundle.report.number).toBe(number)
971
+ expect(bundle.manifest.reportId).toBe(bundle.report.id)
972
+ expect(bundle.manifest.evidenceRevision).toBe(bundle.report.evidenceRevision)
973
+ expect(bundle.manifest.files.map(file => file.path)).toEqual(expect.arrayContaining(['report.json', 'summary.md', 'viewer/index.html']))
974
+ const masked = bundle.manifest.files.find(file => file.kind === 'attachment' && file.name === 'attached-preview.png')
975
+ expect(masked?.contentType).toBe('image/png')
976
+ if (!masked)
977
+ throw new Error('The exported masked attachment is missing')
978
+ expect(bundle.files[masked.path]).toEqual(new Uint8Array(maskedPreview.bytes))
979
+ const largeLog = bundle.manifest.files.find(file => file.kind === 'attachment' && file.name === 'attached-large-log.txt')
980
+ expect(largeLog?.contentType).toBe('text/plain')
981
+ if (!largeLog)
982
+ throw new Error('The exported large log attachment is missing')
983
+ expect(bundle.files[largeLog.path]).toEqual(new Uint8Array(feedbackLog))
984
+ expect(bundle.manifest.files.some(file => file.name === 'discarded.txt')).toBe(false)
985
+ await testInfo.attach('feedback-export-masked', { body: Buffer.from(bundle.files[masked.path]!), contentType: 'image/png' })
986
+ })
987
+ {{/if}}
988
+ {{/if}}