@tribe-nest/forge 1.13.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 (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. package/src/utils/structuredData.ts +126 -0
@@ -0,0 +1,313 @@
1
+ // Unit tests for buildWebManifest() — the React-free Forge server builder that
2
+ // turns the extended site-config `pwa` block + the content-doc theme.* colors
3
+ // into a web app manifest. Pure function, no DB, no network.
4
+ //
5
+ // Coverage (pure-function analogue of the 7-point standard):
6
+ // - happy path w/ deep shape assertions (with + without screenshots)
7
+ // - optional-field permutations (screenshots optional; icons partial)
8
+ // - FORGE_STATE-independent minimal-manifest gating (no pwa → not installable)
9
+ // - theme color derivation + fallback (bare + localized values + defaults)
10
+ // - maskable icon derive (icon192 = maskable, icon512 = any)
11
+ // - overrides (shortcuts) merge + precedence
12
+ // - determinism / idempotence (same input → identical output twice)
13
+ //
14
+ // Source: packages/forge/src/server/pwa.ts. Spec: docs/pwa-custom-code-initiative.md.
15
+
16
+ import { describe, it, expect } from "vitest";
17
+ import { buildWebManifest, type BuildWebManifestOptions } from "../pwa";
18
+ import type { SiteConfig, PwaConfig } from "../../data/queries/useWebsite";
19
+ import type { ContentDocument } from "../../content/types";
20
+
21
+ const DEFAULT_PRIMARY = "#6d28d9";
22
+ const DEFAULT_BACKGROUND = "#ffffff";
23
+
24
+ function fullPwa(overrides: Partial<PwaConfig> = {}): PwaConfig {
25
+ return {
26
+ name: "Nova Sound",
27
+ shortName: "Nova",
28
+ description: "The Nova Sound fan app",
29
+ icon96: "https://cdn.tribenest.co/p/nova/icon-96.png",
30
+ icon192: "https://cdn.tribenest.co/p/nova/icon-192.png",
31
+ icon512: "https://cdn.tribenest.co/p/nova/icon-512.png",
32
+ screenshotWide1280X720: "https://cdn.tribenest.co/p/nova/wide.png",
33
+ screenshotNarrow750X1334: "https://cdn.tribenest.co/p/nova/narrow.png",
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function siteConfig(overrides: Partial<SiteConfig> = {}): SiteConfig {
39
+ return {
40
+ currency: "USD",
41
+ metaPixelId: null,
42
+ stripeAccountId: null,
43
+ paystackAccountId: null,
44
+ paymentProviderName: null,
45
+ paymentProviderPublicKey: null,
46
+ stripePublishableKey: null,
47
+ siteName: "Nova Sound",
48
+ pwa: fullPwa(),
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ /** A content document with theme.* color fields set to bare string values. */
54
+ function themedDoc(primary?: string, background?: string): ContentDocument {
55
+ const fields: ContentDocument["fields"] = {};
56
+ if (primary !== undefined) fields["theme.primary"] = { type: "color", localized: false, value: primary };
57
+ if (background !== undefined)
58
+ fields["theme.background"] = { type: "color", localized: false, value: background };
59
+ return { version: 3, defaultLocale: "en", locales: ["en"], fields };
60
+ }
61
+
62
+ describe("buildWebManifest — happy path (full pwaConfig)", () => {
63
+ const manifest = buildWebManifest({
64
+ siteConfig: siteConfig(),
65
+ contentDoc: themedDoc("#112233", "#f5f5f5"),
66
+ });
67
+
68
+ it("produces the full installable manifest shape with deep field assertions", () => {
69
+ expect(manifest).toMatchObject({
70
+ name: "Nova Sound",
71
+ short_name: "Nova",
72
+ description: "The Nova Sound fan app",
73
+ start_url: "/",
74
+ scope: "/",
75
+ display: "standalone",
76
+ orientation: "portrait-primary",
77
+ background_color: "#f5f5f5",
78
+ theme_color: "#112233",
79
+ lang: "en",
80
+ dir: "ltr",
81
+ prefer_related_applications: false,
82
+ categories: ["social", "business", "productivity"],
83
+ });
84
+ });
85
+
86
+ it("derives maskable/any icons from icon192/icon512", () => {
87
+ expect(manifest.icons).toEqual([
88
+ { src: "https://cdn.tribenest.co/p/nova/icon-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
89
+ { src: "https://cdn.tribenest.co/p/nova/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
90
+ ]);
91
+ });
92
+
93
+ it("includes both screenshots with the correct form factors + labels", () => {
94
+ expect(manifest.screenshots).toEqual([
95
+ {
96
+ src: "https://cdn.tribenest.co/p/nova/wide.png",
97
+ sizes: "1280x720",
98
+ type: "image/png",
99
+ form_factor: "wide",
100
+ label: "Desktop view",
101
+ },
102
+ {
103
+ src: "https://cdn.tribenest.co/p/nova/narrow.png",
104
+ sizes: "750x1334",
105
+ type: "image/png",
106
+ form_factor: "narrow",
107
+ label: "Mobile view",
108
+ },
109
+ ]);
110
+ });
111
+ });
112
+
113
+ describe("buildWebManifest — screenshots optional", () => {
114
+ it("omits the screenshots key entirely when neither screenshot is set", () => {
115
+ const manifest = buildWebManifest({
116
+ siteConfig: siteConfig({ pwa: fullPwa({ screenshotWide1280X720: undefined, screenshotNarrow750X1334: undefined }) }),
117
+ contentDoc: themedDoc("#000", "#fff"),
118
+ });
119
+ expect("screenshots" in manifest).toBe(false);
120
+ expect(manifest.screenshots).toBeUndefined();
121
+ // installability is unaffected — icons still present
122
+ expect(manifest.icons.length).toBe(2);
123
+ });
124
+
125
+ it("includes only the wide screenshot when the narrow one is absent", () => {
126
+ const manifest = buildWebManifest({
127
+ siteConfig: siteConfig({ pwa: fullPwa({ screenshotNarrow750X1334: undefined }) }),
128
+ });
129
+ expect(manifest.screenshots).toEqual([
130
+ expect.objectContaining({ form_factor: "wide", sizes: "1280x720" }),
131
+ ]);
132
+ });
133
+
134
+ it("includes only the narrow screenshot when the wide one is absent", () => {
135
+ const manifest = buildWebManifest({
136
+ siteConfig: siteConfig({ pwa: fullPwa({ screenshotWide1280X720: undefined }) }),
137
+ });
138
+ expect(manifest.screenshots).toEqual([
139
+ expect.objectContaining({ form_factor: "narrow", sizes: "750x1334" }),
140
+ ]);
141
+ });
142
+ });
143
+
144
+ describe("buildWebManifest — icon permutations", () => {
145
+ it("emits only the maskable icon when icon512 is missing", () => {
146
+ const manifest = buildWebManifest({
147
+ siteConfig: siteConfig({ pwa: fullPwa({ icon512: "" as unknown as string }) }),
148
+ });
149
+ expect(manifest.icons).toEqual([
150
+ expect.objectContaining({ sizes: "192x192", purpose: "maskable" }),
151
+ ]);
152
+ });
153
+
154
+ it("emits only the any icon when icon192 is missing", () => {
155
+ const manifest = buildWebManifest({
156
+ siteConfig: siteConfig({ pwa: fullPwa({ icon192: "" as unknown as string }) }),
157
+ });
158
+ expect(manifest.icons).toEqual([
159
+ expect.objectContaining({ sizes: "512x512", purpose: "any" }),
160
+ ]);
161
+ });
162
+
163
+ it("emits an empty icons array when both icons are missing (still a valid manifest)", () => {
164
+ const manifest = buildWebManifest({
165
+ siteConfig: siteConfig({ pwa: fullPwa({ icon192: "" as unknown as string, icon512: "" as unknown as string }) }),
166
+ });
167
+ expect(manifest.icons).toEqual([]);
168
+ });
169
+ });
170
+
171
+ describe("buildWebManifest — theme color derivation + fallback", () => {
172
+ it("falls back to Forge default colors when the content doc has no theme fields", () => {
173
+ const manifest = buildWebManifest({ siteConfig: siteConfig(), contentDoc: themedDoc() });
174
+ expect(manifest.theme_color).toBe(DEFAULT_PRIMARY);
175
+ expect(manifest.background_color).toBe(DEFAULT_BACKGROUND);
176
+ });
177
+
178
+ it("falls back to defaults when contentDoc is null/undefined", () => {
179
+ const nullDoc = buildWebManifest({ siteConfig: siteConfig(), contentDoc: null });
180
+ const noDoc = buildWebManifest({ siteConfig: siteConfig() });
181
+ expect(nullDoc.theme_color).toBe(DEFAULT_PRIMARY);
182
+ expect(nullDoc.background_color).toBe(DEFAULT_BACKGROUND);
183
+ expect(noDoc.theme_color).toBe(DEFAULT_PRIMARY);
184
+ expect(noDoc.background_color).toBe(DEFAULT_BACKGROUND);
185
+ });
186
+
187
+ it("resolves a localized theme color using defaultLocale, then first entry", () => {
188
+ const doc: ContentDocument = {
189
+ version: 1,
190
+ defaultLocale: "fr",
191
+ locales: ["fr", "en"],
192
+ fields: {
193
+ "theme.primary": { type: "color", localized: true, value: { en: "#111111", fr: "#222222" } },
194
+ // no default locale key present → first Object.values entry used
195
+ "theme.background": { type: "color", localized: true, value: { en: "#eeeeee" } },
196
+ },
197
+ };
198
+ const manifest = buildWebManifest({ siteConfig: siteConfig(), contentDoc: doc });
199
+ expect(manifest.theme_color).toBe("#222222"); // fr (defaultLocale) wins
200
+ expect(manifest.background_color).toBe("#eeeeee"); // only entry
201
+ });
202
+
203
+ it("ignores an empty-string color value and falls back", () => {
204
+ const manifest = buildWebManifest({ siteConfig: siteConfig(), contentDoc: themedDoc("", "") });
205
+ expect(manifest.theme_color).toBe(DEFAULT_PRIMARY);
206
+ expect(manifest.background_color).toBe(DEFAULT_BACKGROUND);
207
+ });
208
+ });
209
+
210
+ describe("buildWebManifest — no pwaConfig (not installable / minimal)", () => {
211
+ it("returns a minimal name-only manifest with no icons when pwa is null", () => {
212
+ const manifest = buildWebManifest({
213
+ siteConfig: siteConfig({ pwa: null }),
214
+ contentDoc: themedDoc("#abcabc", "#defdef"),
215
+ });
216
+ expect(manifest).toEqual({
217
+ name: "Nova Sound",
218
+ short_name: "Nova Sound",
219
+ start_url: "/",
220
+ scope: "/",
221
+ display: "standalone",
222
+ background_color: "#defdef",
223
+ theme_color: "#abcabc",
224
+ icons: [],
225
+ });
226
+ // not installable — empty icons, no orientation/categories
227
+ expect(manifest.icons).toEqual([]);
228
+ expect(manifest.categories).toBeUndefined();
229
+ expect(manifest.orientation).toBeUndefined();
230
+ });
231
+
232
+ it("falls back name to 'App' when neither pwa nor siteName is present", () => {
233
+ const manifest = buildWebManifest({ siteConfig: siteConfig({ pwa: null, siteName: null }) });
234
+ expect(manifest.name).toBe("App");
235
+ expect(manifest.short_name).toBe("App");
236
+ });
237
+
238
+ it("returns the minimal manifest when siteConfig itself is null", () => {
239
+ const manifest = buildWebManifest({ siteConfig: null });
240
+ expect(manifest.name).toBe("App");
241
+ expect(manifest.icons).toEqual([]);
242
+ expect(manifest.theme_color).toBe(DEFAULT_PRIMARY);
243
+ expect(manifest.background_color).toBe(DEFAULT_BACKGROUND);
244
+ });
245
+ });
246
+
247
+ describe("buildWebManifest — name/short_name fallback chain", () => {
248
+ it("uses pwa.name over siteName", () => {
249
+ const manifest = buildWebManifest({
250
+ siteConfig: siteConfig({ siteName: "Legal Name", pwa: fullPwa({ name: "Brand" }) }),
251
+ });
252
+ expect(manifest.name).toBe("Brand");
253
+ });
254
+
255
+ it("falls short_name back to pwa.name when shortName is empty", () => {
256
+ const manifest = buildWebManifest({
257
+ siteConfig: siteConfig({ pwa: fullPwa({ shortName: "" }) }),
258
+ });
259
+ expect(manifest.short_name).toBe("Nova Sound");
260
+ });
261
+
262
+ it("omits description when it is empty", () => {
263
+ const manifest = buildWebManifest({
264
+ siteConfig: siteConfig({ pwa: fullPwa({ description: "" }) }),
265
+ });
266
+ expect(manifest.description).toBeUndefined();
267
+ });
268
+ });
269
+
270
+ describe("buildWebManifest — overrides (shortcuts) merge", () => {
271
+ const overrides: BuildWebManifestOptions["overrides"] = {
272
+ shortcuts: [
273
+ { name: "Members", short_name: "Members", url: "/i/members" },
274
+ { name: "Account", short_name: "Account", url: "/i/account" },
275
+ ],
276
+ };
277
+
278
+ it("merges overrides.shortcuts into an installable manifest (no hardcoded shortcuts)", () => {
279
+ const withOverrides = buildWebManifest({ siteConfig: siteConfig(), overrides });
280
+ const without = buildWebManifest({ siteConfig: siteConfig() });
281
+ expect(without.shortcuts).toBeUndefined();
282
+ expect(withOverrides.shortcuts).toEqual(overrides!.shortcuts);
283
+ });
284
+
285
+ it("lets overrides win over computed keys (last-spread precedence)", () => {
286
+ const manifest = buildWebManifest({
287
+ siteConfig: siteConfig(),
288
+ overrides: { name: "Overridden", display: "fullscreen" },
289
+ });
290
+ expect(manifest.name).toBe("Overridden");
291
+ expect(manifest.display).toBe("fullscreen");
292
+ });
293
+
294
+ it("applies overrides to the minimal (no-pwa) manifest too", () => {
295
+ const manifest = buildWebManifest({
296
+ siteConfig: siteConfig({ pwa: null }),
297
+ overrides: { display: "browser" },
298
+ });
299
+ expect(manifest.display).toBe("browser");
300
+ expect(manifest.icons).toEqual([]);
301
+ });
302
+ });
303
+
304
+ describe("buildWebManifest — determinism", () => {
305
+ it("returns identical output for identical input on repeated calls", () => {
306
+ const opts = { siteConfig: siteConfig(), contentDoc: themedDoc("#123123", "#321321") };
307
+ const a = buildWebManifest(opts);
308
+ const b = buildWebManifest(opts);
309
+ expect(a).toEqual(b);
310
+ // JSON round-trip stability (the manifest route serializes it)
311
+ expect(JSON.stringify(a)).toEqual(JSON.stringify(b));
312
+ });
313
+ });
@@ -0,0 +1,251 @@
1
+ // Unit tests for forgeServiceWorkerScript() — the React-free Forge server builder
2
+ // that returns the site's service-worker SOURCE as a dependency-free vanilla-JS
3
+ // string (served verbatim by the starter's `/sw.js` route). Pure function.
4
+ //
5
+ // We can't spin up a real ServiceWorkerGlobalScope here, so we assert on the
6
+ // generated source's contract + behavior by:
7
+ // - structural assertions on the cache name (Forge version baked in → old caches
8
+ // purged on activate)
9
+ // - syntactic validity (the string must parse as JS — a broken SW bricks the site)
10
+ // - behavior extraction: we evaluate the script against a fake `self` that records
11
+ // addEventListener handlers, then drive the push/notificationclick/fetch/install/
12
+ // activate handlers with fake events and assert the FROZEN payload contract
13
+ // ({ title, body, type, url, profileId, channelId, spaceId, icon }) matches the
14
+ // client SW, plus offline-fallback + asset-cache + skipWaiting/clients.claim.
15
+ //
16
+ // Source: packages/forge/src/server/pwa.ts. Spec: docs/pwa-custom-code-initiative.md.
17
+
18
+ import { describe, it, expect, vi } from "vitest";
19
+ import { forgeServiceWorkerScript, FORGE_PWA_VERSION } from "../pwa";
20
+
21
+ describe("forgeServiceWorkerScript — source shape", () => {
22
+ it("bakes the Forge version into the cache name (default)", () => {
23
+ const src = forgeServiceWorkerScript();
24
+ expect(src).toContain(`const CACHE = "forge-pwa-v${FORGE_PWA_VERSION}"`);
25
+ expect(src).toContain('const OFFLINE_URL = "/offline"');
26
+ });
27
+
28
+ it("honors an explicit version argument in the cache name + header", () => {
29
+ const src = forgeServiceWorkerScript("9.9.9");
30
+ expect(src).toContain('const CACHE = "forge-pwa-v9.9.9"');
31
+ expect(src).toContain("Forge service worker (v9.9.9)");
32
+ });
33
+
34
+ it("registers install/activate/fetch/push/notificationclick handlers", () => {
35
+ const src = forgeServiceWorkerScript();
36
+ for (const evt of ["install", "activate", "fetch", "push", "notificationclick"]) {
37
+ expect(src).toContain(`addEventListener("${evt}"`);
38
+ }
39
+ // safe self-activation with no HTML precache
40
+ expect(src).toContain("self.skipWaiting()");
41
+ expect(src).toContain("self.clients.claim()");
42
+ });
43
+
44
+ it("is syntactically valid JS (parses without throwing)", () => {
45
+ const src = forgeServiceWorkerScript();
46
+ // `self` is a free identifier in the SW; wrap so Function() can parse it.
47
+ expect(() => new Function("self", src)).not.toThrow();
48
+ });
49
+
50
+ it("is deterministic for a given version", () => {
51
+ expect(forgeServiceWorkerScript("2.0.0")).toEqual(forgeServiceWorkerScript("2.0.0"));
52
+ });
53
+ });
54
+
55
+ // ---- Behavior harness: evaluate the SW source against a fake global. ----
56
+
57
+ type Handler = (event: unknown) => void;
58
+
59
+ interface FakeCache {
60
+ store: Map<string, unknown>;
61
+ addAll: ReturnType<typeof vi.fn>;
62
+ match: ReturnType<typeof vi.fn>;
63
+ put: ReturnType<typeof vi.fn>;
64
+ }
65
+
66
+ function makeFakeCache(): FakeCache {
67
+ const store = new Map<string, unknown>();
68
+ return {
69
+ store,
70
+ addAll: vi.fn(async () => {}),
71
+ match: vi.fn(async (key: unknown) => store.get(String(typeof key === "string" ? key : (key as Request)?.url))),
72
+ put: vi.fn(async () => {}),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Load the SW source and return the registered handlers + the fake globals so a
78
+ * test can drive events. `location.origin` is set so same-origin checks pass.
79
+ */
80
+ function loadSw(version = FORGE_PWA_VERSION) {
81
+ const handlers: Record<string, Handler> = {};
82
+ const cache = makeFakeCache();
83
+ const showNotification = vi.fn(async () => {});
84
+ const openWindow = vi.fn(async () => ({}));
85
+ const focus = vi.fn(() => ({}));
86
+ const matchAll = vi.fn(async () => [] as Array<{ url: string; focus: typeof focus }>);
87
+ const skipWaiting = vi.fn();
88
+ const claim = vi.fn(async () => {});
89
+
90
+ // `caches` is referenced both bare (install/activate) and via `self.caches`
91
+ // in the SW source, so it must exist as a free global AND on `self`.
92
+ const caches = {
93
+ open: vi.fn(async () => cache),
94
+ keys: vi.fn(async () => ["forge-pwa-vOLD", `forge-pwa-v${version}`]),
95
+ delete: vi.fn(async () => true),
96
+ match: vi.fn(async () => undefined),
97
+ };
98
+
99
+ const self: Record<string, unknown> = {
100
+ location: { origin: "https://nova.tribenest.co" },
101
+ caches,
102
+ registration: { showNotification },
103
+ clients: { matchAll, openWindow, claim },
104
+ skipWaiting,
105
+ addEventListener: (name: string, fn: Handler) => {
106
+ handlers[name] = fn;
107
+ },
108
+ };
109
+ (self.clients as { claim: typeof claim }).claim = claim;
110
+
111
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
112
+ new Function("self", "caches", "URL", "Response", "fetch", "Promise", forgeServiceWorkerScript(version)).call(
113
+ undefined,
114
+ self,
115
+ caches,
116
+ URL,
117
+ Response,
118
+ globalThis.fetch,
119
+ Promise,
120
+ );
121
+
122
+ return { handlers, self, caches, cache, showNotification, openWindow, focus, matchAll, skipWaiting, claim };
123
+ }
124
+
125
+ /** A minimal waitUntil-capturing event. */
126
+ function fakeEvent(extra: Record<string, unknown> = {}) {
127
+ let waited: Promise<unknown> | undefined;
128
+ return {
129
+ waitUntil: (p: Promise<unknown>) => {
130
+ waited = p;
131
+ },
132
+ respondWith: vi.fn(),
133
+ get waited() {
134
+ return waited;
135
+ },
136
+ ...extra,
137
+ };
138
+ }
139
+
140
+ describe("forgeServiceWorkerScript — install/activate behavior", () => {
141
+ it("precaches the offline page on install and self-activates", async () => {
142
+ const { handlers, cache, skipWaiting } = loadSw();
143
+ const event = fakeEvent();
144
+ handlers.install!(event);
145
+ await event.waited;
146
+ expect(cache.addAll).toHaveBeenCalledWith(["/offline"]);
147
+ expect(skipWaiting).toHaveBeenCalledOnce();
148
+ });
149
+
150
+ it("purges stale caches (keeps only the current version) and claims clients on activate", async () => {
151
+ const { handlers, caches, claim } = loadSw("3.3.3");
152
+ const event = fakeEvent();
153
+ handlers.activate!(event);
154
+ await event.waited;
155
+ // the OLD cache is deleted, the current one is not
156
+ expect(caches.delete).toHaveBeenCalledWith("forge-pwa-vOLD");
157
+ expect(caches.delete).not.toHaveBeenCalledWith("forge-pwa-v3.3.3");
158
+ expect(claim).toHaveBeenCalledOnce();
159
+ });
160
+ });
161
+
162
+ describe("forgeServiceWorkerScript — push payload contract (frozen, shared with client SW)", () => {
163
+ it("renders a chat notification tagged by channelId with the payload icon", async () => {
164
+ const { handlers, showNotification } = loadSw();
165
+ const event = fakeEvent({
166
+ data: {
167
+ json: () => ({
168
+ title: "New message",
169
+ body: "hey!",
170
+ type: "chat_message",
171
+ url: "/i/chat/42",
172
+ profileId: "prof-1",
173
+ channelId: "chan-9",
174
+ icon: "https://cdn.tribenest.co/p/nova/icon-192.png",
175
+ }),
176
+ },
177
+ });
178
+ handlers.push!(event);
179
+ await event.waited;
180
+ expect(showNotification).toHaveBeenCalledWith("New message", {
181
+ body: "hey!",
182
+ icon: "https://cdn.tribenest.co/p/nova/icon-192.png",
183
+ tag: "chat-chan-9",
184
+ data: { url: "/i/chat/42", type: "chat_message" },
185
+ });
186
+ });
187
+
188
+ it("tags DMs as chat and post notifications by profileId; icon falls back to undefined", async () => {
189
+ const { handlers, showNotification } = loadSw();
190
+ const post = fakeEvent({
191
+ data: { json: () => ({ title: "New post", body: "check it", type: "new_post", url: "/blog/x", profileId: "prof-7" }) },
192
+ });
193
+ handlers.push!(post);
194
+ await post.waited;
195
+ expect(showNotification).toHaveBeenCalledWith("New post", {
196
+ body: "check it",
197
+ icon: undefined,
198
+ tag: "post-prof-7",
199
+ data: { url: "/blog/x", type: "new_post" },
200
+ });
201
+ });
202
+
203
+ it("tags space notifications by spaceId", async () => {
204
+ const { handlers, showNotification } = loadSw();
205
+ const event = fakeEvent({
206
+ data: { json: () => ({ title: "Space update", body: "b", type: "space_post", url: "/s/1", profileId: "p", spaceId: "space-5" }) },
207
+ });
208
+ handlers.push!(event);
209
+ await event.waited;
210
+ expect(showNotification).toHaveBeenCalledWith(
211
+ "Space update",
212
+ expect.objectContaining({ tag: "space-space-5" }),
213
+ );
214
+ });
215
+
216
+ it("swallows a malformed push (no data / bad JSON) without throwing or notifying", async () => {
217
+ const { handlers, showNotification } = loadSw();
218
+ // no data → early return
219
+ const noData = fakeEvent({ data: null });
220
+ expect(() => handlers.push!(noData)).not.toThrow();
221
+ // data.json throws → caught
222
+ const bad = fakeEvent({ data: { json: () => { throw new Error("bad"); } } });
223
+ expect(() => handlers.push!(bad)).not.toThrow();
224
+ expect(showNotification).not.toHaveBeenCalled();
225
+ });
226
+ });
227
+
228
+ describe("forgeServiceWorkerScript — notificationclick", () => {
229
+ it("focuses an existing client matching the deep-link URL", async () => {
230
+ const { handlers, matchAll, openWindow } = loadSw();
231
+ const focus = vi.fn(() => ({}));
232
+ matchAll.mockResolvedValueOnce([{ url: "https://nova.tribenest.co/i/chat/42", focus }]);
233
+ const close = vi.fn();
234
+ const event = fakeEvent({ notification: { close, data: { url: "/i/chat/42" } } });
235
+ handlers.notificationclick!(event);
236
+ await event.waited;
237
+ expect(close).toHaveBeenCalledOnce();
238
+ expect(focus).toHaveBeenCalledOnce();
239
+ expect(openWindow).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it("opens a new window when no client matches, defaulting url to '/'", async () => {
243
+ const { handlers, matchAll, openWindow } = loadSw();
244
+ matchAll.mockResolvedValueOnce([]);
245
+ const close = vi.fn();
246
+ const event = fakeEvent({ notification: { close, data: undefined } });
247
+ handlers.notificationclick!(event);
248
+ await event.waited;
249
+ expect(openWindow).toHaveBeenCalledWith("/");
250
+ });
251
+ });