@podosoft/podokit 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -17
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +28 -20
  18. package/dist/deploy-schema.d.ts +61 -0
  19. package/dist/deploy-schema.js +157 -1
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +77 -5
  30. package/dist/provider.d.ts +1 -0
  31. package/dist/provider.js +12 -4
  32. package/dist/remove.js +1 -1
  33. package/dist/template-files.d.ts +42 -0
  34. package/dist/template-files.js +102 -0
  35. package/dist/templates/fullstack/AGENTS.md +44 -7
  36. package/dist/templates/fullstack/README.md +8 -7
  37. package/dist/templates/fullstack/apps/api/package.json +4 -4
  38. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  39. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  40. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  41. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  42. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  43. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  44. package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
  45. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  47. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  48. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
  49. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  50. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  51. package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
  52. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  53. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  54. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  55. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  56. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  57. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  58. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  59. package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
  60. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  61. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  62. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  63. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  64. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  65. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  66. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  67. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  68. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  69. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  70. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  71. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  72. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  73. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  74. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  75. package/dist/templates/fullstack/apps/web/package.json +1 -1
  76. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  77. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  78. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  79. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  80. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  81. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
  82. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
  83. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  84. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
  85. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  86. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  87. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  88. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  89. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  90. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  91. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  92. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  93. package/dist/templates/fullstack/dot-env.docker.example +7 -2
  94. package/dist/templates/fullstack/dot-env.example +21 -1
  95. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  96. package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
  97. package/dist/templates/fullstack/tests/README.md +40 -1
  98. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  99. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  100. package/dist/templates/fullstack/tests/package.json +2 -2
  101. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  102. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  103. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  104. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  108. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  109. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  127. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  128. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  129. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  130. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  131. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  133. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  134. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  135. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  136. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  137. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  138. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  139. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  140. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  141. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  143. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  144. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  145. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  146. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  147. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  148. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  149. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
  150. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  151. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  152. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
  153. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  154. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  155. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  156. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  157. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  168. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  169. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  170. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  171. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  172. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  173. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  174. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  175. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  176. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  177. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  178. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  179. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  180. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
  181. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  182. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  183. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  184. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  185. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  186. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  187. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
  188. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
  189. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  190. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  191. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  192. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  193. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  194. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
  195. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  196. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  197. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  198. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
  199. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  200. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  201. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  202. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  203. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  204. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  205. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  206. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  207. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  208. package/dist/templates/modules/auth/module.manifest.json +8 -2
  209. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  210. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  211. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  212. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  213. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  214. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  215. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  216. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  217. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  218. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  219. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  220. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  221. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  222. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  223. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  224. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  225. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  226. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  227. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  228. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  229. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  230. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  231. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  232. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  233. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  234. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
  235. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  236. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  237. package/dist/templates/todo/AGENTS.md +46 -6
  238. package/dist/templates/todo/README.md +6 -4
  239. package/dist/text-diff.d.ts +12 -0
  240. package/dist/text-diff.js +111 -0
  241. package/dist/update.d.ts +61 -3
  242. package/dist/update.js +152 -27
  243. package/package.json +2 -2
  244. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  245. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -1,6 +1,8 @@
1
1
  // Read-back helper for the dev SMS sink (the "Mailpit for SMS"). The app posts
2
2
  // outgoing SMS to the sink via SMS_WEBHOOK_URL; tests poll it here to obtain the
3
3
  // delivered code, so the phone-number flow is verified for real (not just "sent").
4
+ // Each test texts a number of its own; the sink is shared with tests on other
5
+ // workers, so no test empties it.
4
6
  const SINK = process.env.SMS_SINK_URL ?? "http://localhost:8095";
5
7
 
6
8
  type SinkMessage = { to: string; body: string; receivedAt: string };
@@ -14,10 +16,6 @@ export async function smsSinkReachable(): Promise<boolean> {
14
16
  }
15
17
  }
16
18
 
17
- export async function clearSms(): Promise<void> {
18
- await fetch(`${SINK}/messages`, { method: "DELETE" }).catch(() => undefined);
19
- }
20
-
21
19
  /** Poll the sink until an SMS to `to` arrives, then return the first 4–8 digit code. */
22
20
  export async function waitForSmsOtp(to: string, timeoutMs = 10_000): Promise<string> {
23
21
  const deadline = Date.now() + timeoutMs;
@@ -1,10 +1,15 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
- import { loadPlaywrightProjects } from "./playwright.extensions";
2
+ import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
3
3
 
4
4
  // admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
5
- // (storageState) which the `ui` project reuses. Serial + single worker because
6
- // tests share one backend/DB. Runs against a live stack on E2E_BASE_URL.
5
+ // (storageState) which the `ui` project reuses, and a `cleanup` project that
6
+ // removes the users the suite created. Runs against a live stack on
7
+ // E2E_BASE_URL. Tests share one backend and database: E2E_WORKERS sets the
8
+ // worker count (default 1), and tests tagged @exclusive, which change state the
9
+ // others rely on, run one at a time in `<project>-exclusive` twins after the
10
+ // others (see playwright.extensions.ts). Each file runs on one worker in order.
7
11
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
12
+ const workers = e2eWorkers();
8
13
  const coreProjects = [
9
14
  { name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
10
15
  { name: "setup", testMatch: /.*\.setup\.ts/, teardown: "cleanup" },
@@ -22,8 +27,9 @@ export default defineConfig({
22
27
  fullyParallel: false,
23
28
  forbidOnly: !!process.env.CI,
24
29
  retries: process.env.CI ? 2 : 0,
25
- workers: 1,
30
+ workers,
31
+ globalSetup: "./playwright.extensions.ts",
26
32
  reporter: [["list"], ["html", { open: "never" }]],
27
33
  use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure" },
28
- projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
34
+ projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
29
35
  });
@@ -52,16 +52,9 @@ async function seedSession(
52
52
  data: { email: account.email, password: account.password },
53
53
  });
54
54
  expect(res.ok(), `sign-in ${account.email}`).toBeTruthy();
55
- // Keep repeated local runs from filling better-auth's 100-session response
56
- // before the newly created current session can appear in account tests. The
57
- // endpoint clears at most one response page, so drain several pages when a
58
- // long-lived local database has accumulated them.
59
- for (let attempt = 0; attempt < 10; attempt += 1) {
60
- const sessionsResponse = await ctx.get("/api/auth/list-sessions");
61
- const sessions = sessionsResponse.ok() ? (await sessionsResponse.json()) as unknown : [];
62
- if (!Array.isArray(sessions) || sessions.length <= 1) break;
63
- expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
64
- }
55
+ // Each run signs in again, so keep a long-lived local database from piling up
56
+ // this account's sessions: the seeded one is the only one left.
57
+ expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
65
58
  writeFileSync(path, `${JSON.stringify(withTestLocale(await ctx.storageState()), null, 2)}\n`);
66
59
  await ctx.dispose();
67
60
  }
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
- import { anonState } from "../helpers/accounts";
2
+ import { ADMIN, anonState } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
- import { smsSinkReachable, clearSms, waitForSmsOtp } from "../helpers/sms";
4
+ import { smsSinkReachable, waitForSmsOtp } from "../helpers/sms";
5
5
 
6
6
  // Each test drives a fresh disposable account (via a real UI login) so the shared
7
7
  // admin/user sessions are never mutated.
@@ -31,7 +31,6 @@ test("account: register and verify a phone number", async ({ page, playwright })
31
31
  test.skip((await phoneInput.count()) === 0, "phone number feature disabled");
32
32
 
33
33
  const phone = `+1555${String(Date.now()).slice(-7)}`;
34
- await clearSms();
35
34
  await phoneInput.fill(phone);
36
35
  await page.getByRole("button", { name: "Send code" }).click();
37
36
  const code = await waitForSmsOtp(phone);
@@ -47,9 +46,9 @@ test("account: change email address", async ({ page, playwright }) => {
47
46
  await expect(page.getByText(/Email address updated|approve the change/)).toBeVisible();
48
47
  });
49
48
 
50
- test("account: delete my own account", async ({ page, playwright }) => {
49
+ test("account: delete my own account", { tag: "@exclusive" }, async ({ page, playwright }) => {
51
50
  const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
52
- await admin.post("/api/auth/sign-in/email", { data: { email: "admin@example.com", password: PW } });
51
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
53
52
  try {
54
53
  // Account deletion is a server toggle (off by default) — enable it, then poll
55
54
  // until the capability is live (config store has a short TTL).
@@ -0,0 +1,46 @@
1
+ import { signInOnSeparateDevices } from "../helpers/devices";
2
+ import { expect, test } from "../helpers/disposable-users";
3
+ import { ready } from "../helpers/hydration";
4
+
5
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
+ const origin = { origin: base };
7
+
8
+ // Better Auth's /list-sessions returns at most 100 sessions, the first ones the
9
+ // database finds, so a browser that signed in after 105 others used to see no
10
+ // "Current" row, and "Sign out other sessions" left sessions behind.
11
+ test("sessions tab shows the current session and signs out every other one past 100", async ({
12
+ browser,
13
+ playwright,
14
+ disposableUsers,
15
+ }) => {
16
+ test.slow();
17
+ const email = `ui-many-sessions-${Date.now()}@example.com`;
18
+ const password = "Podokit3e-Str0ng!pw";
19
+ await disposableUsers.create({ email, password, name: "Many sessions" });
20
+ const statuses = await signInOnSeparateDevices(email, password, 105);
21
+ test.skip(statuses.includes(429), "needs an authentication rate limit above 105 sign-ins per window");
22
+ expect(statuses.every((status) => status === 200)).toBe(true);
23
+
24
+ // Sign the browser in last, so its session is the 106th.
25
+ const signIn = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
26
+ expect((await signIn.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
27
+ const context = await browser.newContext({ storageState: await signIn.storageState() });
28
+ await context.addCookies([{ name: "locale", value: "en", url: base }]);
29
+ const page = await context.newPage();
30
+ try {
31
+ await ready(page, "/account");
32
+ await page.getByRole("button", { name: "Sessions" }).click();
33
+ await expect(page.getByText("106 sessions", { exact: true })).toBeVisible();
34
+ await expect(page.getByText("Current", { exact: true })).toBeVisible();
35
+
36
+ await page.getByRole("button", { name: "Sign out other sessions" }).click();
37
+ await expect(page.getByText("Signed out other sessions")).toBeVisible();
38
+ await expect(page.getByText("1 sessions", { exact: true })).toBeVisible();
39
+ await expect(page.getByText("Current", { exact: true })).toBeVisible();
40
+ const left = (await (await signIn.get("/api/account/sessions?limit=100")).json()) as { total: number };
41
+ expect(left.total).toBe(1);
42
+ } finally {
43
+ await context.close();
44
+ await signIn.dispose();
45
+ }
46
+ });
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
  import { ADMIN } from "../helpers/accounts";
4
- import { exclusive } from "../helpers/exclusive";
4
+ import { paths } from "../helpers/paths";
5
5
 
6
6
  const PNG = Buffer.from(
7
7
  "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
@@ -17,7 +17,7 @@ test("account opens on the profile section @smoke", async ({ page }) => {
17
17
  await ready(page, "/admin/account");
18
18
  await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
19
19
  await expect(page.getByLabel("Name", { exact: true })).toBeVisible();
20
- await expect(page.getByLabel("Email")).toHaveValue("admin@example.com");
20
+ await expect(page.getByLabel("Email")).toHaveValue(ADMIN.email);
21
21
  // save is enabled only once the name changes
22
22
  await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
23
23
  await page.getByLabel("Name", { exact: true }).fill(`Admin Preview ${Date.now()}`);
@@ -33,7 +33,7 @@ test("protected app routes do not inherit the admin shell @smoke", async ({ page
33
33
  await expect(page.getByTestId("sidebar-brand")).toBeVisible();
34
34
  });
35
35
 
36
- test("account displays profile image limits and updates every avatar", async ({ page }) => {
36
+ test("account displays profile image limits and updates every avatar", { tag: "@exclusive" }, async ({ page }) => {
37
37
  await ready(page, "/account");
38
38
  await page.request.delete("/api/account/profile-image");
39
39
  await ready(page, "/account");
@@ -49,8 +49,10 @@ test("account displays profile image limits and updates every avatar", async ({
49
49
  await expect(page.getByText(/Profile image updated|프로필 이미지를 변경했습니다/)).toBeVisible();
50
50
  await expect(page.getByTestId("profile-image-preview").locator("img")).toBeVisible();
51
51
 
52
- await ready(page, "/");
53
- await expect(page.getByTestId("account-menu").locator("img")).toBeVisible();
52
+ if (paths.accountMenu !== null) {
53
+ await ready(page, paths.accountMenu);
54
+ await expect(page.getByTestId("account-menu").locator("img")).toBeVisible();
55
+ }
54
56
  await ready(page, "/admin");
55
57
  await expect(page.getByTestId("sidebar-user-menu").locator("img")).toBeVisible();
56
58
 
@@ -81,13 +83,13 @@ test("account rejects invalid profile images before upload", async ({ page }) =>
81
83
  test("account exposes a change-email action when the address is edited", async ({ page }) => {
82
84
  await ready(page, "/admin/account");
83
85
  const email = page.getByLabel("Email");
84
- await expect(email).toHaveValue("admin@example.com");
86
+ await expect(email).toHaveValue(ADMIN.email);
85
87
  // no action until the address actually changes
86
88
  await expect(page.getByRole("button", { name: "Change email" })).toHaveCount(0);
87
- await email.fill("admin+changed@example.com");
89
+ await email.fill(`changed.${ADMIN.email}`);
88
90
  await expect(page.getByRole("button", { name: "Change email" })).toBeVisible();
89
91
  // revert without submitting — don't mutate the shared admin session
90
- await email.fill("admin@example.com");
92
+ await email.fill(ADMIN.email);
91
93
  await expect(page.getByRole("button", { name: "Change email" })).toHaveCount(0);
92
94
  });
93
95
 
@@ -145,7 +147,7 @@ test("account shows a phone field when enabled", async ({ page }) => {
145
147
  await expect(phone).toBeVisible();
146
148
  });
147
149
 
148
- test("admin can update their profile name", async ({ page }) => {
150
+ test("admin can update their profile name", { tag: "@exclusive" }, async ({ page }) => {
149
151
  await ready(page, "/admin/account");
150
152
  const name = page.getByLabel("Name", { exact: true });
151
153
  await name.fill(`Admin Updated ${Date.now()}`);
@@ -174,8 +176,8 @@ test("account security and sessions sub-navigation", async ({ page }) => {
174
176
  /**
175
177
  * ⚠ THE SUITE ALWAYS SIGNS IN FIRST, WHICH IS WHY THIS NEEDED A MOCK.
176
178
  *
177
- * better-auth guards `/list-sessions` with its freshness middleware and
178
- * `session.freshAge` defaults to 24 hours, so the endpoint returns 403
179
+ * The sessions list keeps better-auth's freshness rule, and `session.freshAge`
180
+ * defaults to 24 hours, so `GET /api/account/sessions` returns 403
179
181
  * `SESSION_NOT_FRESH` to anyone who signed in yesterday. The seeded session is
180
182
  * minutes old, so the test above passes for every run while the screen is broken for
181
183
  * real users — the tab showed an empty table under an untranslated "Session is not
@@ -183,11 +185,14 @@ test("account security and sessions sub-navigation", async ({ page }) => {
183
185
  * own setup prevents.
184
186
  */
185
187
  test("sessions tab explains a session that is too old to list them", async ({ page }) => {
186
- await page.route("**/api/auth/list-sessions", (route) =>
188
+ await page.route("**/api/account/sessions?*", (route) =>
187
189
  route.fulfill({
188
190
  status: 403,
189
191
  contentType: "application/json",
190
- body: JSON.stringify({ message: "Session is not fresh", code: "SESSION_NOT_FRESH" }),
192
+ body: JSON.stringify({
193
+ success: false,
194
+ error: { code: "SESSION_NOT_FRESH", message: "Session is not fresh", statusCode: 403 },
195
+ }),
191
196
  }),
192
197
  );
193
198
 
@@ -227,58 +232,56 @@ test("account nav shows the core sections", async ({ page }) => {
227
232
  await expect(page.getByRole("button", { name: "Sessions" })).toBeVisible();
228
233
  });
229
234
 
230
- test("connected account unlink uses the local account row id @smoke", async ({ page }) => {
235
+ test("connected account unlink uses the local account row id @smoke", { tag: "@exclusive" }, async ({ page }) => {
231
236
  test.slow();
232
- await exclusive("social-providers", async () => {
233
- const provider = "google";
234
- let unlinkBody: unknown;
235
- await page.route("**/api/auth/list-accounts", (route) =>
236
- route.fulfill({
237
- status: 200,
238
- contentType: "application/json",
239
- body: JSON.stringify([
240
- { id: "local-account-id", providerId: provider, accountId: "provider-subject" },
241
- ]),
242
- }),
243
- );
244
- await page.route("**/api/auth/unlink-account", async (route) => {
245
- unlinkBody = route.request().postDataJSON();
246
- await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
247
- });
237
+ const provider = "google";
238
+ let unlinkBody: unknown;
239
+ await page.route("**/api/auth/list-accounts", (route) =>
240
+ route.fulfill({
241
+ status: 200,
242
+ contentType: "application/json",
243
+ body: JSON.stringify([
244
+ { id: "local-account-id", providerId: provider, accountId: "provider-subject" },
245
+ ]),
246
+ }),
247
+ );
248
+ await page.route("**/api/auth/unlink-account", async (route) => {
249
+ unlinkBody = route.request().postDataJSON();
250
+ await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
251
+ });
248
252
 
249
- try {
250
- const enabled = await page.request.put("/api/account/auth-config", {
251
- data: {
252
- social: {
253
- [provider]: {
254
- enabled: true,
255
- clientId: "podokit",
256
- clientSecret: "podokit",
257
- },
253
+ try {
254
+ const enabled = await page.request.put("/api/account/auth-config", {
255
+ data: {
256
+ social: {
257
+ [provider]: {
258
+ enabled: true,
259
+ clientId: "podokit",
260
+ clientSecret: "podokit",
258
261
  },
259
262
  },
260
- });
261
- expect(enabled.ok()).toBeTruthy();
262
- await expect
263
- .poll(async () => {
264
- const response = await page.request.get("/api/account/capabilities");
265
- const body = (await response.json()) as { providers?: string[] };
266
- return body.providers ?? [];
267
- }, { timeout: 8_000 })
268
- .toContain(provider);
263
+ },
264
+ });
265
+ expect(enabled.ok()).toBeTruthy();
266
+ await expect
267
+ .poll(async () => {
268
+ const response = await page.request.get("/api/account/capabilities");
269
+ const body = (await response.json()) as { providers?: string[] };
270
+ return body.providers ?? [];
271
+ }, { timeout: 8_000 })
272
+ .toContain(provider);
269
273
 
270
- await ready(page, "/admin/account");
271
- await page.getByRole("button", { name: "Connected", exact: true }).click();
272
- await page.getByRole("button", { name: "Disconnect", exact: true }).click();
274
+ await ready(page, "/admin/account");
275
+ await page.getByRole("button", { name: "Connected", exact: true }).click();
276
+ await page.getByRole("button", { name: "Disconnect", exact: true }).click();
273
277
 
274
- await expect.poll(() => unlinkBody).toEqual({ accountId: "local-account-id" });
275
- await expect(page.getByText("Disconnected", { exact: true })).toBeVisible();
276
- } finally {
277
- await page.request.put("/api/account/auth-config", {
278
- data: { social: { [provider]: { enabled: false } } },
279
- });
280
- }
281
- });
278
+ await expect.poll(() => unlinkBody).toEqual({ accountId: "local-account-id" });
279
+ await expect(page.getByText("Disconnected", { exact: true })).toBeVisible();
280
+ } finally {
281
+ await page.request.put("/api/account/auth-config", {
282
+ data: { social: { [provider]: { enabled: false } } },
283
+ });
284
+ }
282
285
  });
283
286
 
284
287
  test("two-factor setup shows a scannable QR code when enabled", async ({ page }) => {
@@ -0,0 +1,63 @@
1
+ import { expect, test, type Page } from "@playwright/test";
2
+ import { APP_FLAG_SETTINGS } from "../../apps/web/src/lib/admin/app-flags";
3
+ import { ready } from "../helpers/hydration";
4
+
5
+ // admin storageState (project default). The Application tab lists the flags of
6
+ // the app-owned apps/web/src/lib/admin/app-flags.ts; a generated app has none.
7
+ // "only admins can change settings" covers the endpoint's refusal for other users.
8
+ const listed = APP_FLAG_SETTINGS.map((setting) => setting.flag);
9
+
10
+ async function appFlags(page: Page): Promise<Record<string, boolean>> {
11
+ const response = await page.request.get("/api/account/capabilities");
12
+ expect(response.ok()).toBeTruthy();
13
+ return ((await response.json()) as { appFlags?: Record<string, boolean> }).appFlags ?? {};
14
+ }
15
+
16
+ test("the Application tab shows the flags app-flags.ts lists, and only then", async ({ page }) => {
17
+ await ready(page, "/admin/settings");
18
+ await expect(page.getByRole("tab", { name: "Authentication" })).toBeVisible();
19
+ const tab = page.getByRole("tab", { name: "Application" });
20
+ if (listed.length === 0) {
21
+ await expect(tab).toHaveCount(0);
22
+ return;
23
+ }
24
+ await tab.click();
25
+ const cards = page.locator("[data-app-flag]");
26
+ await expect(cards).toHaveCount(listed.length);
27
+ expect(await cards.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-app-flag"))))
28
+ .toEqual(listed);
29
+ const flags = await appFlags(page);
30
+ for (const flag of listed) {
31
+ expect(flags, `${flag} is in APP_FLAG_DEFAULTS`).toHaveProperty(flag);
32
+ await expect(page.locator(`[data-app-flag="${flag}"]`).getByRole("switch"))
33
+ .toHaveAttribute("aria-checked", String(flags[flag]));
34
+ }
35
+ });
36
+
37
+ test("an application flag switch saves through the settings endpoint", { tag: "@exclusive" }, async ({ page }) => {
38
+ test.skip(listed.length === 0, "app-flags.ts lists no application flags");
39
+ const flag = listed[0] ?? "";
40
+ const original = (await appFlags(page))[flag] === true;
41
+ await ready(page, "/admin/settings");
42
+ await page.getByRole("tab", { name: "Application" }).click();
43
+ const toggle = page.locator(`[data-app-flag="${flag}"]`).getByRole("switch");
44
+ await expect(toggle).toHaveAttribute("aria-checked", String(original));
45
+
46
+ try {
47
+ const saved = page.waitForResponse(
48
+ (response) => response.url().endsWith("/api/account/settings") && response.request().method() === "PUT",
49
+ );
50
+ await toggle.click();
51
+ expect((await saved).ok()).toBeTruthy();
52
+ await expect(page.getByText("Setting updated").last()).toBeVisible();
53
+ await expect.poll(async () => (await appFlags(page))[flag]).toBe(!original);
54
+
55
+ // The stored value survives a reload.
56
+ await ready(page, "/admin/settings");
57
+ await page.getByRole("tab", { name: "Application" }).click();
58
+ await expect(page.locator(`[data-app-flag="${flag}"]`).getByRole("switch"))
59
+ .toHaveAttribute("aria-checked", String(!original));
60
+ } finally {
61
+ await page.request.put("/api/account/settings", { data: { [flag]: original } });
62
+ }
63
+ });
@@ -1,6 +1,5 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
- import { exclusive } from "../helpers/exclusive";
4
3
 
5
4
  // admin storageState (project default). The Appearance controls live in their
6
5
  // own "Appearance" settings tab.
@@ -49,20 +48,17 @@ test("appearance: advanced reveals per-token pickers", async ({ page }) => {
49
48
  await expect(page.getByLabel("Primary dark")).toBeVisible();
50
49
  });
51
50
 
52
- test("appearance: pick a preset, save, apply live, then restore @smoke", async ({ page }) => {
53
- test.slow();
54
- await exclusive("site-theme", async () => {
55
- await openAppearance(page);
56
- await page.getByRole("button", { name: "Slate" }).click();
57
- await page.getByRole("button", { name: "Medium" }).click();
58
- await page.getByRole("button", { name: "Save changes" }).click();
59
- await expect(page.getByText("General settings saved.").first()).toBeVisible();
60
- // Applied globally via the scoped override stylesheet.
61
- await expect.poll(async () => page.locator("#podokit-theme").count()).toBeGreaterThan(0);
51
+ test("appearance: pick a preset, save, apply live, then restore @smoke", { tag: "@exclusive" }, async ({ page }) => {
52
+ await openAppearance(page);
53
+ await page.getByRole("button", { name: "Slate" }).click();
54
+ await page.getByRole("button", { name: "Medium" }).click();
55
+ await page.getByRole("button", { name: "Save changes" }).click();
56
+ await expect(page.getByText("General settings saved.").first()).toBeVisible();
57
+ // Applied globally via the scoped override stylesheet.
58
+ await expect.poll(async () => page.locator("#podokit-theme").count()).toBeGreaterThan(0);
62
59
 
63
- // "Restore defaults" reverts everything in one click and persists.
64
- await page.getByRole("button", { name: "Restore defaults" }).click();
65
- await expect(page.getByText("General settings saved.").first()).toBeVisible();
66
- await expect.poll(async () => page.locator("#podokit-theme").count()).toBe(0);
67
- });
60
+ // "Restore defaults" reverts everything in one click and persists.
61
+ await page.getByRole("button", { name: "Restore defaults" }).click();
62
+ await expect(page.getByText("General settings saved.").first()).toBeVisible();
63
+ await expect.poll(async () => page.locator("#podokit-theme").count()).toBe(0);
68
64
  });
@@ -1,4 +1,5 @@
1
1
  import { expect, test } from "@playwright/test";
2
+ import { ADMIN } from "../helpers/accounts";
2
3
  import { ready } from "../helpers/hydration";
3
4
 
4
5
  // admin storageState (project default). The Audit log page is gated by the
@@ -23,7 +24,7 @@ test("admin filters the audit log on the server", async ({ page }) => {
23
24
 
24
25
  await page.locator('button[data-slot="select-trigger"]').first().click(); // Action filter
25
26
  await page.getByRole("option", { name: "auth.login", exact: true }).click();
26
- await page.locator("#toolbar-search").fill("admin@example.com");
27
+ await page.locator("#toolbar-search").fill(ADMIN.email);
27
28
  const filtered = page.waitForResponse((response) =>
28
29
  response.url().includes("/api/audit-logs?") &&
29
30
  response.url().includes("action=auth.login") &&
@@ -35,7 +36,7 @@ test("admin filters the audit log on the server", async ({ page }) => {
35
36
  const actions = table.locator("tbody tr td:nth-child(3)");
36
37
  await expect(actions.first()).toHaveText("auth.login");
37
38
  for (const text of await actions.allInnerTexts()) expect(text).toBe("auth.login");
38
- await expect(table.getByText("admin@example.com").first()).toBeVisible();
39
+ await expect(table.getByText(ADMIN.email).first()).toBeVisible();
39
40
  });
40
41
 
41
42
  test("admin pages through the audit log", async ({ page }) => {
@@ -2,7 +2,6 @@ import { expect, test } from "@playwright/test";
2
2
  import { safeAuthRedirect, withAuthRedirect } from "../../apps/web/src/lib/auth-redirect";
3
3
  import { anonState, ADMIN } from "../helpers/accounts";
4
4
  import { ready } from "../helpers/hydration";
5
- import { exclusive } from "../helpers/exclusive";
6
5
 
7
6
  test.use({ storageState: anonState });
8
7
 
@@ -66,43 +65,41 @@ test("closed social registration errors explain that existing users may sign in"
66
65
  );
67
66
  });
68
67
 
69
- test("configured social providers appear on the login page", async ({ page, playwright }) => {
68
+ test("configured social providers appear on the login page", { tag: "@exclusive" }, async ({ page, playwright }) => {
70
69
  test.slow();
71
- await exclusive("social-providers", async () => {
72
- const admin = await playwright.request.newContext({
73
- baseURL: base,
74
- extraHTTPHeaders: { origin: base },
75
- });
76
- await admin.post("/api/auth/sign-in/email", {
77
- data: { email: ADMIN.email, password: ADMIN.password },
78
- });
79
- try {
80
- expect(
81
- (await admin.put("/api/account/auth-config", {
82
- data: {
83
- social: {
84
- google: {
85
- enabled: true,
86
- clientId: "dummy-google-client-id",
87
- clientSecret: "dummy-google-client-secret",
88
- },
70
+ const admin = await playwright.request.newContext({
71
+ baseURL: base,
72
+ extraHTTPHeaders: { origin: base },
73
+ });
74
+ await admin.post("/api/auth/sign-in/email", {
75
+ data: { email: ADMIN.email, password: ADMIN.password },
76
+ });
77
+ try {
78
+ expect(
79
+ (await admin.put("/api/account/auth-config", {
80
+ data: {
81
+ social: {
82
+ google: {
83
+ enabled: true,
84
+ clientId: "dummy-google-client-id",
85
+ clientSecret: "dummy-google-client-secret",
89
86
  },
90
87
  },
91
- })).ok(),
92
- ).toBeTruthy();
93
- await expect
94
- .poll(async () => {
95
- await page.goto("/login");
96
- return page.getByRole("button", { name: "Continue with Google" }).count();
97
- }, { timeout: 8_000 })
98
- .toBe(1);
99
- } finally {
100
- await admin.put("/api/account/auth-config", {
101
- data: { social: { google: { enabled: false } } },
102
- });
103
- await admin.dispose();
104
- }
105
- });
88
+ },
89
+ })).ok(),
90
+ ).toBeTruthy();
91
+ await expect
92
+ .poll(async () => {
93
+ await page.goto("/login");
94
+ return page.getByRole("button", { name: "Continue with Google" }).count();
95
+ }, { timeout: 8_000 })
96
+ .toBe(1);
97
+ } finally {
98
+ await admin.put("/api/account/auth-config", {
99
+ data: { social: { google: { enabled: false } } },
100
+ });
101
+ await admin.dispose();
102
+ }
106
103
  });
107
104
 
108
105
  test("forgot-password shows a sent confirmation", async ({ page }) => {
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
+ import { paths } from "../helpers/paths";
4
5
 
5
6
  // admin storageState (project default)
6
7
  test("overview shows the signed-in admin @smoke", async ({ page }) => {
@@ -11,7 +12,8 @@ test("overview shows the signed-in admin @smoke", async ({ page }) => {
11
12
  });
12
13
 
13
14
  test("landing shows language, theme, and signed-in account actions", async ({ page }) => {
14
- await ready(page, "/");
15
+ test.skip(paths.accountMenu === null, "no page renders the account menu (tests/helpers/app-paths.ts)");
16
+ await ready(page, paths.accountMenu ?? "/");
15
17
  const language = page.getByRole("button", { name: /^(Language|언어)$/ });
16
18
  const theme = page.getByRole("button", { name: /^(Toggle theme|테마 전환)$/ });
17
19
  await expect(language).toBeVisible();
@@ -33,7 +35,7 @@ test("sidebar navigates to sessions and account @smoke", async ({ page }) => {
33
35
  await expect(page.getByRole("heading", { name: "User sessions" })).toBeVisible();
34
36
  // Account is reached from the user menu at the bottom of the sidebar, not the nav
35
37
  await expect(page.getByRole("link", { name: "Account" })).toHaveCount(0);
36
- await page.getByRole("button", { name: /admin@example.com/ }).click();
38
+ await page.getByTestId("sidebar-user-menu").click();
37
39
  await page.getByRole("menuitem", { name: "Account" }).click();
38
40
  await expect(page).toHaveURL((url) => url.pathname === "/account");
39
41
  await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
@@ -1,6 +1,6 @@
1
1
  import { expect, test } from "../helpers/disposable-users";
2
2
  import { ready } from "../helpers/hydration";
3
- import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
3
+ import { mailpitReachable, waitForLink } from "../helpers/mailpit";
4
4
 
5
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
6
 
@@ -21,7 +21,6 @@ test("admin sends a password reset email to a user", async ({ page, disposableUs
21
21
  await ready(page, "/admin/users");
22
22
  const email = `admin-reset-${Date.now()}@example.com`;
23
23
  await disposableUsers.create({ email, name: "AR" });
24
- await clearMailpit();
25
24
  await page.locator("#toolbar-search").fill(email);
26
25
  await page.getByRole("button", { name: "Search", exact: true }).click();
27
26
  await page.getByRole("row", { name: new RegExp(email) }).getByRole("button").click();
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { anonState } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
- import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
4
+ import { mailpitReachable, waitForLink } from "../helpers/mailpit";
5
5
 
6
6
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
7
7
  const origin = { origin: base };
@@ -20,7 +20,6 @@ test("forgot password: request, open the emailed link, set a new password, sign
20
20
  // The happy path ends by signing in; when verification is required that would be
21
21
  // blocked, so this UI walkthrough only applies with verification off.
22
22
  test.skip(verificationOn, "email verification enabled");
23
- await clearMailpit();
24
23
 
25
24
  await ready(page, "/forgot-password");
26
25
  await page.getByLabel("Email").fill(email);