@shipfox/client-integrations 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +4 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +207 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/connection-picker.d.ts +7 -0
  10. package/dist/components/connection-picker.d.ts.map +1 -0
  11. package/dist/components/connection-picker.js +98 -0
  12. package/dist/components/connection-picker.js.map +1 -0
  13. package/dist/components/installed-integrations-section.d.ts +14 -0
  14. package/dist/components/installed-integrations-section.d.ts.map +1 -0
  15. package/dist/components/installed-integrations-section.js +203 -0
  16. package/dist/components/installed-integrations-section.js.map +1 -0
  17. package/dist/components/integration-delete-confirm-modal.d.ts +10 -0
  18. package/dist/components/integration-delete-confirm-modal.d.ts.map +1 -0
  19. package/dist/components/integration-delete-confirm-modal.js +61 -0
  20. package/dist/components/integration-delete-confirm-modal.js.map +1 -0
  21. package/dist/components/integration-gallery-for-workspace.d.ts +9 -0
  22. package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -0
  23. package/dist/components/integration-gallery-for-workspace.js +169 -0
  24. package/dist/components/integration-gallery-for-workspace.js.map +1 -0
  25. package/dist/components/integration-gallery.d.ts +8 -0
  26. package/dist/components/integration-gallery.d.ts.map +1 -0
  27. package/dist/components/integration-gallery.js +26 -0
  28. package/dist/components/integration-gallery.js.map +1 -0
  29. package/dist/components/integration-gallery.stories.d.ts +26 -0
  30. package/dist/components/integration-gallery.stories.d.ts.map +1 -0
  31. package/dist/components/integration-gallery.stories.js +335 -0
  32. package/dist/components/integration-gallery.stories.js.map +1 -0
  33. package/dist/components/integration-usage-events.d.ts +4 -0
  34. package/dist/components/integration-usage-events.d.ts.map +1 -0
  35. package/dist/components/integration-usage-events.js +127 -0
  36. package/dist/components/integration-usage-events.js.map +1 -0
  37. package/dist/components/integration-usage-modal.d.ts +16 -0
  38. package/dist/components/integration-usage-modal.d.ts.map +1 -0
  39. package/dist/components/integration-usage-modal.js +184 -0
  40. package/dist/components/integration-usage-modal.js.map +1 -0
  41. package/dist/components/provider-grid.d.ts +14 -0
  42. package/dist/components/provider-grid.d.ts.map +1 -0
  43. package/dist/components/provider-grid.js +152 -0
  44. package/dist/components/provider-grid.js.map +1 -0
  45. package/dist/components/redirect-install-page.d.ts +19 -0
  46. package/dist/components/redirect-install-page.d.ts.map +1 -0
  47. package/dist/components/redirect-install-page.js +68 -0
  48. package/dist/components/redirect-install-page.js.map +1 -0
  49. package/dist/components/repository-picker.d.ts +15 -0
  50. package/dist/components/repository-picker.d.ts.map +1 -0
  51. package/dist/components/repository-picker.js +78 -0
  52. package/dist/components/repository-picker.js.map +1 -0
  53. package/dist/components/webhook/copyable-value.d.ts +10 -0
  54. package/dist/components/webhook/copyable-value.d.ts.map +1 -0
  55. package/dist/components/webhook/copyable-value.js +65 -0
  56. package/dist/components/webhook/copyable-value.js.map +1 -0
  57. package/dist/components/webhook/webhook-create-modal.d.ts +10 -0
  58. package/dist/components/webhook/webhook-create-modal.d.ts.map +1 -0
  59. package/dist/components/webhook/webhook-create-modal.js +233 -0
  60. package/dist/components/webhook/webhook-create-modal.js.map +1 -0
  61. package/dist/components/webhook/webhook-form-errors.d.ts +11 -0
  62. package/dist/components/webhook/webhook-form-errors.d.ts.map +1 -0
  63. package/dist/components/webhook/webhook-form-errors.js +27 -0
  64. package/dist/components/webhook/webhook-form-errors.js.map +1 -0
  65. package/dist/components/webhook/webhook-modals.stories.d.ts +25 -0
  66. package/dist/components/webhook/webhook-modals.stories.d.ts.map +1 -0
  67. package/dist/components/webhook/webhook-modals.stories.js +222 -0
  68. package/dist/components/webhook/webhook-modals.stories.js.map +1 -0
  69. package/dist/components/webhook/webhook-public-endpoint-alert.d.ts +2 -0
  70. package/dist/components/webhook/webhook-public-endpoint-alert.d.ts.map +1 -0
  71. package/dist/components/webhook/webhook-public-endpoint-alert.js +20 -0
  72. package/dist/components/webhook/webhook-public-endpoint-alert.js.map +1 -0
  73. package/dist/components/webhook/webhook-usage-details.d.ts +7 -0
  74. package/dist/components/webhook/webhook-usage-details.d.ts.map +1 -0
  75. package/dist/components/webhook/webhook-usage-details.js +49 -0
  76. package/dist/components/webhook/webhook-usage-details.js.map +1 -0
  77. package/dist/connection-status-badge.d.ts +12 -0
  78. package/dist/connection-status-badge.d.ts.map +1 -0
  79. package/dist/connection-status-badge.js +34 -0
  80. package/dist/connection-status-badge.js.map +1 -0
  81. package/dist/feature.d.ts +37 -0
  82. package/dist/feature.d.ts.map +1 -0
  83. package/dist/feature.js +48 -0
  84. package/dist/feature.js.map +1 -0
  85. package/dist/hooks/api/integrations.d.ts +265 -0
  86. package/dist/hooks/api/integrations.d.ts.map +1 -0
  87. package/dist/hooks/api/integrations.js +229 -0
  88. package/dist/hooks/api/integrations.js.map +1 -0
  89. package/dist/hooks/api/webhook-connections.d.ts +91 -0
  90. package/dist/hooks/api/webhook-connections.d.ts.map +1 -0
  91. package/dist/hooks/api/webhook-connections.js +97 -0
  92. package/dist/hooks/api/webhook-connections.js.map +1 -0
  93. package/dist/index.d.ts +17 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +18 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/integration-icon.d.ts +17 -0
  98. package/dist/integration-icon.d.ts.map +1 -0
  99. package/dist/integration-icon.js +22 -0
  100. package/dist/integration-icon.js.map +1 -0
  101. package/dist/linear-callback.d.ts +17 -0
  102. package/dist/linear-callback.d.ts.map +1 -0
  103. package/dist/linear-callback.js +113 -0
  104. package/dist/linear-callback.js.map +1 -0
  105. package/dist/pages/gitea-form-errors.d.ts +11 -0
  106. package/dist/pages/gitea-form-errors.d.ts.map +1 -0
  107. package/dist/pages/gitea-form-errors.js +35 -0
  108. package/dist/pages/gitea-form-errors.js.map +1 -0
  109. package/dist/pages/gitea-install-page.d.ts +2 -0
  110. package/dist/pages/gitea-install-page.d.ts.map +1 -0
  111. package/dist/pages/gitea-install-page.js +139 -0
  112. package/dist/pages/gitea-install-page.js.map +1 -0
  113. package/dist/pages/github-install-page.d.ts +2 -0
  114. package/dist/pages/github-install-page.d.ts.map +1 -0
  115. package/dist/pages/github-install-page.js +11 -0
  116. package/dist/pages/github-install-page.js.map +1 -0
  117. package/dist/pages/linear-callback-page.d.ts +2 -0
  118. package/dist/pages/linear-callback-page.d.ts.map +1 -0
  119. package/dist/pages/linear-callback-page.js +192 -0
  120. package/dist/pages/linear-callback-page.js.map +1 -0
  121. package/dist/pages/linear-install-page.d.ts +2 -0
  122. package/dist/pages/linear-install-page.d.ts.map +1 -0
  123. package/dist/pages/linear-install-page.js +24 -0
  124. package/dist/pages/linear-install-page.js.map +1 -0
  125. package/dist/pages/sentry-callback-page.d.ts +2 -0
  126. package/dist/pages/sentry-callback-page.d.ts.map +1 -0
  127. package/dist/pages/sentry-callback-page.js +256 -0
  128. package/dist/pages/sentry-callback-page.js.map +1 -0
  129. package/dist/pages/sentry-install-page.d.ts +2 -0
  130. package/dist/pages/sentry-install-page.d.ts.map +1 -0
  131. package/dist/pages/sentry-install-page.js +17 -0
  132. package/dist/pages/sentry-install-page.js.map +1 -0
  133. package/dist/pages/source-control-onboarding-page.d.ts +2 -0
  134. package/dist/pages/source-control-onboarding-page.d.ts.map +1 -0
  135. package/dist/pages/source-control-onboarding-page.js +37 -0
  136. package/dist/pages/source-control-onboarding-page.js.map +1 -0
  137. package/dist/provider-catalog.d.ts +15 -0
  138. package/dist/provider-catalog.d.ts.map +1 -0
  139. package/dist/provider-catalog.js +28 -0
  140. package/dist/provider-catalog.js.map +1 -0
  141. package/dist/routes/gitea.d.ts +6 -0
  142. package/dist/routes/gitea.d.ts.map +1 -0
  143. package/dist/routes/gitea.js +7 -0
  144. package/dist/routes/gitea.js.map +1 -0
  145. package/dist/routes/github-callback.d.ts +6 -0
  146. package/dist/routes/github-callback.d.ts.map +1 -0
  147. package/dist/routes/github-callback.js +129 -0
  148. package/dist/routes/github-callback.js.map +1 -0
  149. package/dist/routes/github.d.ts +6 -0
  150. package/dist/routes/github.d.ts.map +1 -0
  151. package/dist/routes/github.js +7 -0
  152. package/dist/routes/github.js.map +1 -0
  153. package/dist/routes/integrations.d.ts +6 -0
  154. package/dist/routes/integrations.d.ts.map +1 -0
  155. package/dist/routes/integrations.js +7 -0
  156. package/dist/routes/integrations.js.map +1 -0
  157. package/dist/routes/linear-callback.d.ts +6 -0
  158. package/dist/routes/linear-callback.d.ts.map +1 -0
  159. package/dist/routes/linear-callback.js +7 -0
  160. package/dist/routes/linear-callback.js.map +1 -0
  161. package/dist/routes/linear.d.ts +6 -0
  162. package/dist/routes/linear.d.ts.map +1 -0
  163. package/dist/routes/linear.js +7 -0
  164. package/dist/routes/linear.js.map +1 -0
  165. package/dist/routes/sentry-callback.d.ts +6 -0
  166. package/dist/routes/sentry-callback.d.ts.map +1 -0
  167. package/dist/routes/sentry-callback.js +7 -0
  168. package/dist/routes/sentry-callback.js.map +1 -0
  169. package/dist/routes/sentry.d.ts +6 -0
  170. package/dist/routes/sentry.d.ts.map +1 -0
  171. package/dist/routes/sentry.js +7 -0
  172. package/dist/routes/sentry.js.map +1 -0
  173. package/dist/sentry-callback.d.ts +35 -0
  174. package/dist/sentry-callback.d.ts.map +1 -0
  175. package/dist/sentry-callback.js +132 -0
  176. package/dist/sentry-callback.js.map +1 -0
  177. package/dist/tsconfig.test.tsbuildinfo +1 -0
  178. package/package.json +120 -0
  179. package/src/components/connection-picker.tsx +89 -0
  180. package/src/components/installed-integrations-section.tsx +198 -0
  181. package/src/components/integration-delete-confirm-modal.tsx +62 -0
  182. package/src/components/integration-gallery-for-workspace.tsx +193 -0
  183. package/src/components/integration-gallery.stories.tsx +302 -0
  184. package/src/components/integration-gallery.test.tsx +692 -0
  185. package/src/components/integration-gallery.tsx +49 -0
  186. package/src/components/integration-usage-events.test.ts +80 -0
  187. package/src/components/integration-usage-events.ts +116 -0
  188. package/src/components/integration-usage-modal.tsx +178 -0
  189. package/src/components/provider-grid.tsx +163 -0
  190. package/src/components/redirect-install-page.test.tsx +98 -0
  191. package/src/components/redirect-install-page.tsx +68 -0
  192. package/src/components/repository-picker.tsx +102 -0
  193. package/src/components/webhook/copyable-value.test.tsx +41 -0
  194. package/src/components/webhook/copyable-value.tsx +72 -0
  195. package/src/components/webhook/webhook-create-modal.tsx +247 -0
  196. package/src/components/webhook/webhook-form-errors.test.ts +45 -0
  197. package/src/components/webhook/webhook-form-errors.ts +32 -0
  198. package/src/components/webhook/webhook-modals.stories.tsx +225 -0
  199. package/src/components/webhook/webhook-public-endpoint-alert.tsx +15 -0
  200. package/src/components/webhook/webhook-usage-details.tsx +44 -0
  201. package/src/connection-status-badge.tsx +38 -0
  202. package/src/feature.ts +47 -0
  203. package/src/hooks/api/integrations.test.ts +105 -0
  204. package/src/hooks/api/integrations.ts +287 -0
  205. package/src/hooks/api/webhook-connections.ts +115 -0
  206. package/src/index.ts +16 -0
  207. package/src/integration-icon.react.test.tsx +40 -0
  208. package/src/integration-icon.test.ts +19 -0
  209. package/src/integration-icon.tsx +26 -0
  210. package/src/linear-callback.test.ts +227 -0
  211. package/src/linear-callback.ts +136 -0
  212. package/src/pages/gitea-form-errors.test.ts +55 -0
  213. package/src/pages/gitea-form-errors.ts +36 -0
  214. package/src/pages/gitea-install-page.tsx +108 -0
  215. package/src/pages/github-install-page.tsx +11 -0
  216. package/src/pages/linear-callback-page.test.tsx +157 -0
  217. package/src/pages/linear-callback-page.tsx +175 -0
  218. package/src/pages/linear-install-page.tsx +26 -0
  219. package/src/pages/sentry-callback-page.test.tsx +205 -0
  220. package/src/pages/sentry-callback-page.tsx +238 -0
  221. package/src/pages/sentry-install-page.tsx +17 -0
  222. package/src/pages/source-control-onboarding-page.test.tsx +46 -0
  223. package/src/pages/source-control-onboarding-page.tsx +26 -0
  224. package/src/provider-catalog.test.ts +42 -0
  225. package/src/provider-catalog.ts +49 -0
  226. package/src/routes/gitea.tsx +4 -0
  227. package/src/routes/github-callback.tsx +114 -0
  228. package/src/routes/github.tsx +4 -0
  229. package/src/routes/integrations.tsx +4 -0
  230. package/src/routes/linear-callback.tsx +4 -0
  231. package/src/routes/linear.tsx +4 -0
  232. package/src/routes/sentry-callback.tsx +4 -0
  233. package/src/routes/sentry.tsx +4 -0
  234. package/src/sentry-callback.test.ts +239 -0
  235. package/src/sentry-callback.ts +142 -0
  236. package/test/render.tsx +111 -0
  237. package/test/setup.ts +3 -0
  238. package/tsconfig.build.json +9 -0
  239. package/tsconfig.build.tsbuildinfo +1 -0
  240. package/tsconfig.json +3 -0
  241. package/tsconfig.test.json +8 -0
  242. package/vercel.json +8 -0
  243. package/vitest.config.ts +75 -0
@@ -0,0 +1,692 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
4
+ import {configureApiClient} from '@shipfox/client-api';
5
+ import {fireEvent, screen, waitFor, within} from '@testing-library/react';
6
+ import {INTEGRATIONS_TEST_WID, jsonResponse, renderIntegrationsPage} from '#test/render.js';
7
+ import {IntegrationGallery} from './integration-gallery.js';
8
+
9
+ if (!HTMLElement.prototype.hasPointerCapture) {
10
+ Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
11
+ configurable: true,
12
+ value: () => false,
13
+ });
14
+ }
15
+
16
+ if (!HTMLElement.prototype.setPointerCapture) {
17
+ Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
18
+ configurable: true,
19
+ value: () => undefined,
20
+ });
21
+ }
22
+
23
+ if (!HTMLElement.prototype.releasePointerCapture) {
24
+ Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
25
+ configurable: true,
26
+ value: () => undefined,
27
+ });
28
+ }
29
+
30
+ const SETUP_ROUTES = [
31
+ '/workspaces/$wid/integrations/github',
32
+ '/workspaces/$wid/integrations/sentry',
33
+ '/workspaces/$wid/settings/events',
34
+ ];
35
+ const EXTERNAL_SETTINGS_ACTION_RE = /Open.*settings/u;
36
+
37
+ const defaultProviders = [
38
+ {provider: 'github', display_name: 'GitHub', capabilities: ['source_control']},
39
+ {provider: 'sentry', display_name: 'Sentry', capabilities: []},
40
+ {provider: 'webhook', display_name: 'Webhook', capabilities: []},
41
+ ];
42
+
43
+ const githubConnection = {
44
+ id: '44444444-4444-4444-8444-444444444444',
45
+ workspace_id: INTEGRATIONS_TEST_WID,
46
+ provider: 'github',
47
+ external_account_id: 'installation-1',
48
+ slug: 'github_acme_corp',
49
+ display_name: 'acme-corp',
50
+ lifecycle_status: 'active',
51
+ capabilities: ['source_control'],
52
+ external_url: 'https://github.com/organizations/acme-corp/settings/installations/1',
53
+ created_at: '2026-03-12T00:00:00.000Z',
54
+ updated_at: '2026-03-12T00:00:00.000Z',
55
+ } satisfies IntegrationConnectionDto;
56
+
57
+ const webhookConnection = {
58
+ id: '77777777-7777-4777-8777-777777777777',
59
+ workspace_id: INTEGRATIONS_TEST_WID,
60
+ provider: 'webhook',
61
+ external_account_id: 'stripe-prod',
62
+ slug: 'stripe-prod',
63
+ display_name: 'Stripe production',
64
+ lifecycle_status: 'active',
65
+ capabilities: [],
66
+ created_at: '2026-04-12T00:00:00.000Z',
67
+ updated_at: '2026-04-12T00:00:00.000Z',
68
+ };
69
+
70
+ const webhookConnectionDto = {
71
+ id: webhookConnection.id,
72
+ workspace_id: INTEGRATIONS_TEST_WID,
73
+ name: 'Stripe production',
74
+ slug: 'stripe-prod',
75
+ lifecycle_status: 'active',
76
+ inbound_url: 'https://api.example.test/webhook/77777777-7777-4777-8777-777777777777',
77
+ created_at: webhookConnection.created_at,
78
+ updated_at: webhookConnection.updated_at,
79
+ };
80
+
81
+ interface FetchOptions {
82
+ providers?: unknown[];
83
+ connections?: unknown[];
84
+ webhookConnections?: unknown[];
85
+ providersFail?: boolean;
86
+ connectionsFail?: boolean;
87
+ webhookConnectionsFail?: boolean;
88
+ onUpdateConnection?: (connectionId: string, body: {lifecycle_status?: string}) => void;
89
+ }
90
+
91
+ function fetchForGallery(options: FetchOptions = {}) {
92
+ const {
93
+ providers = defaultProviders,
94
+ connections = [],
95
+ webhookConnections = [webhookConnectionDto],
96
+ providersFail = false,
97
+ connectionsFail = false,
98
+ webhookConnectionsFail = false,
99
+ onUpdateConnection,
100
+ } = options;
101
+ return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
102
+ const request = input instanceof Request ? input : undefined;
103
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
104
+ const method = init?.method ?? request?.method ?? 'GET';
105
+ if (url.includes('/integrations/webhook/connections/')) {
106
+ const connectionId = url.split('/integrations/webhook/connections/')[1]?.split('?')[0];
107
+ if (!connectionId) return Promise.resolve(jsonResponse({}, {status: 404}));
108
+ if (method === 'PATCH') {
109
+ const rawBody =
110
+ init?.body !== undefined ? String(init.body) : (await request?.clone().text()) || '{}';
111
+ const body = JSON.parse(rawBody) as {lifecycle_status?: string};
112
+ onUpdateConnection?.(connectionId, body);
113
+ const connection = webhookConnections.find(
114
+ (candidate) =>
115
+ typeof candidate === 'object' &&
116
+ candidate !== null &&
117
+ 'id' in candidate &&
118
+ candidate.id === connectionId,
119
+ ) as Record<string, unknown> | undefined;
120
+ return Promise.resolve(
121
+ jsonResponse({...connection, lifecycle_status: body.lifecycle_status}),
122
+ );
123
+ }
124
+ if (method === 'DELETE') {
125
+ return Promise.resolve(jsonResponse(undefined, {status: 204}));
126
+ }
127
+ }
128
+ if (url.includes('/integrations/webhook/connections')) {
129
+ if (webhookConnectionsFail)
130
+ return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
131
+ if (method === 'POST') {
132
+ const rawBody =
133
+ init?.body !== undefined ? String(init.body) : (await request?.clone().text()) || '{}';
134
+ const body = JSON.parse(rawBody) as {name?: string; slug?: string; workspace_id?: string};
135
+ return Promise.resolve(
136
+ jsonResponse({
137
+ ...webhookConnectionDto,
138
+ workspace_id: body.workspace_id ?? webhookConnectionDto.workspace_id,
139
+ name: body.name ?? webhookConnectionDto.name,
140
+ slug: body.slug ?? webhookConnectionDto.slug,
141
+ }),
142
+ );
143
+ }
144
+ return Promise.resolve(jsonResponse({connections: webhookConnections}));
145
+ }
146
+ if (url.includes('/integration-providers')) {
147
+ if (providersFail)
148
+ return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
149
+ return Promise.resolve(jsonResponse({providers}));
150
+ }
151
+ if (url.includes('/integration-connections/')) {
152
+ const connectionId = url.split('/integration-connections/')[1]?.split('?')[0];
153
+ if (!connectionId) return Promise.resolve(jsonResponse({}, {status: 404}));
154
+ if (method === 'PATCH') {
155
+ const rawBody =
156
+ init?.body !== undefined ? String(init.body) : (await request?.clone().text()) || '{}';
157
+ const body = JSON.parse(rawBody) as {lifecycle_status?: string};
158
+ onUpdateConnection?.(connectionId, body);
159
+ const connection = connections.find(
160
+ (candidate) =>
161
+ typeof candidate === 'object' &&
162
+ candidate !== null &&
163
+ 'id' in candidate &&
164
+ candidate.id === connectionId,
165
+ ) as Record<string, unknown> | undefined;
166
+ return Promise.resolve(
167
+ jsonResponse({...connection, lifecycle_status: body.lifecycle_status}),
168
+ );
169
+ }
170
+ if (method === 'DELETE') {
171
+ return Promise.resolve(jsonResponse(undefined, {status: 204}));
172
+ }
173
+ }
174
+ if (url.includes('/integration-connections')) {
175
+ if (connectionsFail)
176
+ return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
177
+ return Promise.resolve(jsonResponse({connections}));
178
+ }
179
+ return Promise.resolve(jsonResponse({}, {status: 404}));
180
+ });
181
+ }
182
+
183
+ function renderGallery(
184
+ props: Parameters<typeof IntegrationGallery>[0] = {},
185
+ options: FetchOptions = {},
186
+ ) {
187
+ configureApiClient({
188
+ baseUrl: 'https://api.example.test',
189
+ fetchImpl: fetchForGallery(options),
190
+ });
191
+ return renderIntegrationsPage({
192
+ path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
193
+ routePath: '/workspaces/$wid/integrations',
194
+ element: <IntegrationGallery workspaceId={INTEGRATIONS_TEST_WID} {...props} />,
195
+ extraRoutes: SETUP_ROUTES,
196
+ });
197
+ }
198
+
199
+ const installedRegion = () => screen.getByRole('region', {name: 'Installed integrations'});
200
+ const availableRegion = () => screen.getByRole('region', {name: 'Available integrations'});
201
+ const openActions = async (name: string) => {
202
+ fireEvent.pointerDown(await screen.findByRole('button', {name}));
203
+ };
204
+
205
+ const SORTED_NAMES_RE = /acme-(early|late)/;
206
+ // The meta line carries the date only — the provider is named once, by the
207
+ // icon and the account name, never repeated as body text in the row.
208
+ const ADDED_META_RE = /^Added /;
209
+
210
+ describe('IntegrationGallery — installed section', () => {
211
+ test('renders two distinct cards for two connections sharing one provider', async () => {
212
+ renderGallery(
213
+ {},
214
+ {
215
+ connections: [
216
+ {
217
+ ...githubConnection,
218
+ id: '11111111-1111-4111-8111-aaaaaaaaaaaa',
219
+ display_name: 'acme-one',
220
+ },
221
+ {
222
+ ...githubConnection,
223
+ id: '22222222-2222-4222-8222-bbbbbbbbbbbb',
224
+ display_name: 'acme-two',
225
+ },
226
+ ],
227
+ },
228
+ );
229
+
230
+ expect(await screen.findByText('acme-one')).toBeVisible();
231
+ expect(screen.getByText('acme-two')).toBeVisible();
232
+ expect(screen.getByText('Provider accounts installed in this workspace.')).toBeVisible();
233
+ });
234
+
235
+ test('sorts stably by provider name then created_at regardless of input order', async () => {
236
+ renderGallery(
237
+ {},
238
+ {
239
+ connections: [
240
+ {
241
+ ...githubConnection,
242
+ id: '22222222-2222-4222-8222-bbbbbbbbbbbb',
243
+ display_name: 'acme-late',
244
+ created_at: '2026-05-01T00:00:00.000Z',
245
+ },
246
+ {
247
+ ...githubConnection,
248
+ id: '11111111-1111-4111-8111-aaaaaaaaaaaa',
249
+ display_name: 'acme-early',
250
+ created_at: '2026-01-01T00:00:00.000Z',
251
+ },
252
+ ],
253
+ },
254
+ );
255
+
256
+ await screen.findByText('acme-early');
257
+ const names = within(installedRegion())
258
+ .getAllByText(SORTED_NAMES_RE)
259
+ .map((element) => element.textContent);
260
+
261
+ expect(names).toEqual(['acme-early', 'acme-late']);
262
+ });
263
+
264
+ test('renders lifecycle badges only for states that need attention', async () => {
265
+ renderGallery(
266
+ {},
267
+ {
268
+ connections: [
269
+ {
270
+ ...githubConnection,
271
+ id: '11111111-1111-4111-8111-aaaaaaaaaaaa',
272
+ lifecycle_status: 'active',
273
+ },
274
+ {
275
+ ...githubConnection,
276
+ id: '22222222-2222-4222-8222-bbbbbbbbbbbb',
277
+ display_name: 'acme-disabled',
278
+ lifecycle_status: 'disabled',
279
+ },
280
+ {
281
+ ...githubConnection,
282
+ id: '33333333-3333-4333-8333-cccccccccccc',
283
+ display_name: 'acme-error',
284
+ lifecycle_status: 'error',
285
+ },
286
+ ],
287
+ },
288
+ );
289
+
290
+ expect(await screen.findByText('acme-corp')).toBeVisible();
291
+ expect(screen.queryByText('Connected')).not.toBeInTheDocument();
292
+ expect(screen.getByText('Disabled')).toBeVisible();
293
+ expect(screen.getByText('Error')).toBeVisible();
294
+ });
295
+
296
+ test('mutes the title of a disabled connection', async () => {
297
+ renderGallery({}, {connections: [{...githubConnection, lifecycle_status: 'disabled'}]});
298
+
299
+ const title = await screen.findByText('acme-corp');
300
+
301
+ expect(title).toHaveClass('text-foreground-neutral-disabled');
302
+ });
303
+
304
+ test('shows the standard actions menu for installed rows', async () => {
305
+ renderGallery({}, {connections: [webhookConnection]});
306
+
307
+ expect(await screen.findByText('Stripe production')).toBeVisible();
308
+ await openActions('Open Stripe production integration actions');
309
+
310
+ expect(screen.getByRole('menuitem', {name: 'Use this integration'})).toBeVisible();
311
+ expect(screen.getByRole('menuitem', {name: 'View recent events'})).toBeVisible();
312
+ expect(screen.getByRole('menuitem', {name: 'Disable integration'})).toBeVisible();
313
+ expect(screen.getByRole('menuitem', {name: 'Delete integration'})).toBeVisible();
314
+ });
315
+
316
+ test('opens external provider settings safely when a connection exposes a URL', async () => {
317
+ renderGallery({}, {connections: [githubConnection]});
318
+
319
+ await openActions('Open acme-corp integration actions');
320
+
321
+ const externalAction = screen.getByRole('menuitem', {name: 'Open in GitHub'});
322
+ expect(externalAction).toHaveAttribute('href', githubConnection.external_url);
323
+ expect(externalAction).toHaveAttribute('target', '_blank');
324
+ expect(externalAction).toHaveAttribute('rel', 'noreferrer noopener');
325
+ });
326
+
327
+ test('uses generic external copy without provider metadata and omits it without a URL', async () => {
328
+ const gallery = renderGallery({}, {providers: [], connections: [githubConnection]});
329
+
330
+ await openActions('Open acme-corp integration actions');
331
+ expect(screen.getByRole('menuitem', {name: 'Open provider settings'})).toHaveAttribute(
332
+ 'href',
333
+ githubConnection.external_url,
334
+ );
335
+
336
+ gallery.unmount();
337
+ renderGallery({}, {providers: [], connections: [webhookConnection]});
338
+ await openActions('Open Stripe production integration actions');
339
+ expect(
340
+ screen.queryByRole('menuitem', {name: EXTERNAL_SETTINGS_ACTION_RE}),
341
+ ).not.toBeInTheDocument();
342
+ });
343
+
344
+ test('opens the standard usage modal with webhook details', async () => {
345
+ renderGallery({}, {connections: [webhookConnection]});
346
+
347
+ await openActions('Open Stripe production integration actions');
348
+ fireEvent.click(screen.getByRole('menuitem', {name: 'Use this integration'}));
349
+
350
+ expect(await screen.findByText('Usage')).toBeVisible();
351
+ expect(await screen.findByText('Inbound URL')).toBeVisible();
352
+ expect(screen.getByText(webhookConnectionDto.inbound_url)).toBeVisible();
353
+ });
354
+
355
+ test('opens the usage modal with a GitHub event selector', async () => {
356
+ renderGallery({}, {connections: [githubConnection]});
357
+
358
+ await openActions('Open acme-corp integration actions');
359
+ fireEvent.click(screen.getByRole('menuitem', {name: 'Use this integration'}));
360
+
361
+ expect(await screen.findByText('Usage')).toBeVisible();
362
+ const eventSelect = await screen.findByRole('combobox', {name: 'Event'});
363
+
364
+ expect(eventSelect).toBeVisible();
365
+ await waitFor(() => {
366
+ expect(eventSelect).toHaveTextContent('push');
367
+ });
368
+ });
369
+
370
+ test('toggles integration lifecycle status from the actions menu', async () => {
371
+ const updates: Array<{connectionId: string; body: {lifecycle_status?: string}}> = [];
372
+ const fetchImpl = fetchForGallery({
373
+ connections: [githubConnection],
374
+ onUpdateConnection: (connectionId, body) => updates.push({connectionId, body}),
375
+ });
376
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
377
+ renderIntegrationsPage({
378
+ path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
379
+ routePath: '/workspaces/$wid/integrations',
380
+ element: <IntegrationGallery />,
381
+ extraRoutes: SETUP_ROUTES,
382
+ });
383
+
384
+ await openActions('Open acme-corp integration actions');
385
+ fireEvent.click(screen.getByRole('menuitem', {name: 'Disable integration'}));
386
+
387
+ await screen.findByText('Integration disabled.');
388
+ const updateRequest = fetchImpl.mock.calls
389
+ .map(([input]) => input)
390
+ .find(
391
+ (input) =>
392
+ input instanceof Request &&
393
+ input.url.includes(`/integration-connections/${githubConnection.id}`),
394
+ );
395
+ expect(updateRequest).toBeInstanceOf(Request);
396
+ expect((updateRequest as Request).method).toBe('PATCH');
397
+ expect(updates).toEqual([
398
+ {
399
+ connectionId: githubConnection.id,
400
+ body: {lifecycle_status: 'disabled'},
401
+ },
402
+ ]);
403
+ });
404
+
405
+ test('toggles webhook connections through the webhook endpoint', async () => {
406
+ const updates: Array<{connectionId: string; body: {lifecycle_status?: string}}> = [];
407
+ const fetchImpl = fetchForGallery({
408
+ connections: [webhookConnection],
409
+ onUpdateConnection: (connectionId, body) => updates.push({connectionId, body}),
410
+ });
411
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
412
+ renderIntegrationsPage({
413
+ path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
414
+ routePath: '/workspaces/$wid/integrations',
415
+ element: <IntegrationGallery />,
416
+ extraRoutes: SETUP_ROUTES,
417
+ });
418
+
419
+ await openActions('Open Stripe production integration actions');
420
+ fireEvent.click(screen.getByRole('menuitem', {name: 'Disable integration'}));
421
+
422
+ await screen.findByText('Integration disabled.');
423
+ const updateRequest = fetchImpl.mock.calls
424
+ .map(([input]) => input)
425
+ .find(
426
+ (input) =>
427
+ input instanceof Request &&
428
+ input.url.includes(`/integrations/webhook/connections/${webhookConnection.id}`),
429
+ );
430
+ expect(updateRequest).toBeInstanceOf(Request);
431
+ expect((updateRequest as Request).method).toBe('PATCH');
432
+ expect(updates).toEqual([
433
+ {
434
+ connectionId: webhookConnection.id,
435
+ body: {lifecycle_status: 'disabled'},
436
+ },
437
+ ]);
438
+ });
439
+
440
+ test('deletes webhook connections through the webhook endpoint', async () => {
441
+ const fetchImpl = fetchForGallery({connections: [webhookConnection]});
442
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
443
+ renderIntegrationsPage({
444
+ path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
445
+ routePath: '/workspaces/$wid/integrations',
446
+ element: <IntegrationGallery />,
447
+ extraRoutes: SETUP_ROUTES,
448
+ });
449
+
450
+ await openActions('Open Stripe production integration actions');
451
+ fireEvent.click(screen.getByRole('menuitem', {name: 'Delete integration'}));
452
+ fireEvent.click(await screen.findByRole('button', {name: 'Delete integration'}));
453
+
454
+ await screen.findByText('Integration deleted.');
455
+ const deleteRequest = fetchImpl.mock.calls
456
+ .map(([input]) => input)
457
+ .find(
458
+ (input) =>
459
+ input instanceof Request &&
460
+ input.url.includes(`/integrations/webhook/connections/${webhookConnection.id}`) &&
461
+ input.method === 'DELETE',
462
+ );
463
+ expect(deleteRequest).toBeInstanceOf(Request);
464
+ });
465
+
466
+ test('opens the delete confirmation from the actions menu', async () => {
467
+ renderGallery({}, {connections: [githubConnection]});
468
+
469
+ await openActions('Open acme-corp integration actions');
470
+ fireEvent.click(screen.getByRole('menuitem', {name: 'Delete integration'}));
471
+
472
+ const dialog = await screen.findByRole('dialog', {name: 'Delete integration'});
473
+ const integrationName = within(dialog).getByText('acme-corp');
474
+
475
+ expect(dialog).toHaveTextContent(
476
+ 'Are you sure you want to delete acme-corp? Once deleted, Shipfox will immediately stop processing events from this integration. This cannot be undone.',
477
+ );
478
+ expect(integrationName.tagName).toBe('STRONG');
479
+ expect(integrationName).toHaveClass('font-medium');
480
+ expect(within(dialog).getByRole('button', {name: 'Delete integration'})).toBeVisible();
481
+ });
482
+
483
+ test('filters connections by capability in memory', async () => {
484
+ renderGallery(
485
+ {capability: 'source_control'},
486
+ {
487
+ connections: [
488
+ githubConnection,
489
+ {
490
+ ...githubConnection,
491
+ id: '22222222-2222-4222-8222-bbbbbbbbbbbb',
492
+ provider: 'sentry',
493
+ display_name: 'sentry-org',
494
+ capabilities: [],
495
+ },
496
+ ],
497
+ },
498
+ );
499
+
500
+ expect(await screen.findByText('acme-corp')).toBeVisible();
501
+ expect(screen.queryByText('sentry-org')).not.toBeInTheDocument();
502
+ });
503
+
504
+ test('renders a connection whose provider is missing from the catalog', async () => {
505
+ renderGallery(
506
+ {},
507
+ {connections: [{...githubConnection, provider: 'mystery', display_name: 'mystery-acct'}]},
508
+ );
509
+
510
+ expect(await screen.findByText('mystery-acct')).toBeVisible();
511
+ expect(within(installedRegion()).getByText(ADDED_META_RE)).toBeVisible();
512
+ });
513
+
514
+ test('names the provider once per row — not repeated in the meta line', async () => {
515
+ renderGallery({}, {connections: [githubConnection]});
516
+
517
+ await screen.findByText('acme-corp');
518
+
519
+ const region = installedRegion();
520
+ expect(within(region).getByText(ADDED_META_RE)).toBeVisible();
521
+ expect(within(region).queryByText('GitHub')).not.toBeInTheDocument();
522
+ });
523
+
524
+ test('renders installed cards even when the providers query fails', async () => {
525
+ renderGallery({}, {providersFail: true, connections: [githubConnection]});
526
+
527
+ expect(await screen.findByText('acme-corp')).toBeVisible();
528
+ expect(within(installedRegion()).getByText(ADDED_META_RE)).toBeVisible();
529
+ expect(
530
+ screen.getByRole('button', {name: 'Open acme-corp integration actions'}),
531
+ ).toBeInTheDocument();
532
+ expect(
533
+ within(availableRegion()).getByText("Couldn't load available integrations"),
534
+ ).toBeInTheDocument();
535
+ });
536
+
537
+ test('surfaces a connections error only in the installed section (settings context)', async () => {
538
+ renderGallery({}, {connectionsFail: true});
539
+
540
+ expect(await screen.findByText("Couldn't load integrations")).toBeInTheDocument();
541
+ expect(screen.queryByText("Couldn't load available integrations")).not.toBeInTheDocument();
542
+ expect(within(availableRegion()).getByRole('link', {name: 'Install GitHub'})).toBeVisible();
543
+ });
544
+
545
+ test('shows the empty state in the settings context', async () => {
546
+ renderGallery({}, {connections: []});
547
+
548
+ expect(await screen.findByText('No integrations installed yet')).toBeVisible();
549
+ });
550
+ });
551
+
552
+ describe('IntegrationGallery — available section', () => {
553
+ test('lists every provider with an Install link, including installed ones', async () => {
554
+ renderGallery({}, {connections: [githubConnection]});
555
+
556
+ expect(await screen.findByRole('link', {name: 'Install GitHub'})).toBeVisible();
557
+ expect(screen.getByRole('link', {name: 'Install Sentry'})).toBeVisible();
558
+ expect(screen.getByRole('button', {name: 'Add Webhook'})).toBeVisible();
559
+ expect(screen.getByText('Providers available to install in this workspace.')).toBeVisible();
560
+ });
561
+
562
+ test('exposes each available tile as a single link with no nested button', async () => {
563
+ renderGallery({}, {connections: [githubConnection]});
564
+
565
+ const link = await screen.findByRole('link', {name: 'Install GitHub'});
566
+
567
+ expect(link).toHaveClass('focus-visible:shadow-button-neutral-focus');
568
+ expect(link.className).not.toContain('shadow-button-secondary');
569
+ expect(within(link).getByText('Install')).toBeVisible();
570
+ expect(within(link).queryByRole('button')).not.toBeInTheDocument();
571
+ });
572
+
573
+ test('opens the webhook create modal from the Add card', async () => {
574
+ renderGallery({}, {connections: []});
575
+
576
+ fireEvent.click(await screen.findByRole('button', {name: 'Add Webhook'}));
577
+
578
+ expect(await screen.findByRole('textbox', {name: 'Name'})).toBeVisible();
579
+ expect(screen.getByRole('textbox', {name: 'Slug'})).toBeVisible();
580
+ });
581
+
582
+ test('opens the usage modal after creating a webhook', async () => {
583
+ renderGallery({}, {connections: []});
584
+
585
+ fireEvent.click(await screen.findByRole('button', {name: 'Add Webhook'}));
586
+ fireEvent.change(await screen.findByRole('textbox', {name: 'Name'}), {
587
+ target: {value: 'Stripe production'},
588
+ });
589
+ fireEvent.click(screen.getByRole('button', {name: 'Create'}));
590
+
591
+ expect(await screen.findByText('Usage')).toBeVisible();
592
+ expect(screen.getAllByText('webhook-stripe-production')[0]).toBeVisible();
593
+ expect(await screen.findByText('Inbound URL')).toBeVisible();
594
+ expect(screen.getByText(webhookConnectionDto.inbound_url)).toBeVisible();
595
+ expect(screen.queryByRole('textbox', {name: 'Name'})).not.toBeInTheDocument();
596
+ });
597
+
598
+ test('fills an empty webhook slug from the name', async () => {
599
+ renderGallery({}, {connections: []});
600
+
601
+ fireEvent.click(await screen.findByRole('button', {name: 'Add Webhook'}));
602
+ fireEvent.change(await screen.findByRole('textbox', {name: 'Name'}), {
603
+ target: {value: 'Stripe production'},
604
+ });
605
+
606
+ await waitFor(() => {
607
+ expect(screen.getByRole('textbox', {name: 'Slug'})).toHaveValue('webhook-stripe-production');
608
+ });
609
+ });
610
+
611
+ test('preserves a non-empty manual webhook slug until the slug is cleared', async () => {
612
+ renderGallery({}, {connections: []});
613
+
614
+ fireEvent.click(await screen.findByRole('button', {name: 'Add Webhook'}));
615
+ fireEvent.change(await screen.findByRole('textbox', {name: 'Name'}), {
616
+ target: {value: 'Stripe production'},
617
+ });
618
+ await waitFor(() => {
619
+ expect(screen.getByRole('textbox', {name: 'Slug'})).toHaveValue('webhook-stripe-production');
620
+ });
621
+
622
+ fireEvent.change(screen.getByRole('textbox', {name: 'Slug'}), {
623
+ target: {value: 'custom-stripe-hook'},
624
+ });
625
+ fireEvent.change(screen.getByRole('textbox', {name: 'Name'}), {
626
+ target: {value: 'Billing alerts'},
627
+ });
628
+
629
+ expect(screen.getByRole('textbox', {name: 'Slug'})).toHaveValue('custom-stripe-hook');
630
+
631
+ fireEvent.change(screen.getByRole('textbox', {name: 'Slug'}), {
632
+ target: {value: ''},
633
+ });
634
+
635
+ await waitFor(() => {
636
+ expect(screen.getByRole('textbox', {name: 'Slug'})).toHaveValue('webhook-billing-alerts');
637
+ });
638
+ });
639
+
640
+ test('rejects reserved webhook slugs inline', async () => {
641
+ renderGallery({}, {connections: []});
642
+
643
+ fireEvent.click(await screen.findByRole('button', {name: 'Add Webhook'}));
644
+ fireEvent.change(await screen.findByRole('textbox', {name: 'Name'}), {
645
+ target: {value: 'Stripe production'},
646
+ });
647
+ fireEvent.change(screen.getByRole('textbox', {name: 'Slug'}), {
648
+ target: {value: 'github'},
649
+ });
650
+ fireEvent.click(screen.getByRole('button', {name: 'Create'}));
651
+
652
+ expect(await screen.findByText('That slug is reserved.')).toBeVisible();
653
+ });
654
+
655
+ test('validates webhook names after trimming whitespace', async () => {
656
+ renderGallery({}, {connections: []});
657
+
658
+ fireEvent.click(await screen.findByRole('button', {name: 'Add Webhook'}));
659
+ fireEvent.change(await screen.findByRole('textbox', {name: 'Name'}), {
660
+ target: {value: ' '},
661
+ });
662
+ fireEvent.change(screen.getByRole('textbox', {name: 'Slug'}), {
663
+ target: {value: 'stripe-prod'},
664
+ });
665
+ fireEvent.click(screen.getByRole('button', {name: 'Create'}));
666
+
667
+ expect(await screen.findByText('Webhook name is required.')).toBeVisible();
668
+ });
669
+
670
+ test('surfaces a providers error only in the available section', async () => {
671
+ renderGallery({}, {providersFail: true, connections: [githubConnection]});
672
+
673
+ expect(await screen.findByText("Couldn't load available integrations")).toBeInTheDocument();
674
+ expect(screen.queryByText("Couldn't load integrations")).not.toBeInTheDocument();
675
+ });
676
+
677
+ test('skips providers with no catalog entry', async () => {
678
+ renderGallery(
679
+ {},
680
+ {
681
+ providers: [
682
+ ...defaultProviders,
683
+ {provider: 'mystery', display_name: 'Mystery', capabilities: []},
684
+ ],
685
+ },
686
+ );
687
+
688
+ await screen.findByRole('link', {name: 'Install GitHub'});
689
+
690
+ expect(screen.queryByRole('link', {name: 'Install Mystery'})).not.toBeInTheDocument();
691
+ });
692
+ });