@jskit-ai/connectors-web 0.1.1

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.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Integration configuration fields
2
+
3
+ `IntegrationConfigurationFields` is a controlled Vue/Vuetify component imported
4
+ from `@jskit-ai/connectors-web/client`. It edits the same configuration accepted
5
+ by `@jskit-ai/connectors-core/shared/configuration`.
6
+
7
+ ```vue
8
+ <IntegrationConfigurationFields
9
+ v-model="configuration"
10
+ integration-id="calendar"
11
+ :provider="googleCalendarDefinition"
12
+ :field-errors="fieldErrors"
13
+ :disabled="saving"
14
+ :callback-url="resolvedCallbackUrl"
15
+ />
16
+ ```
17
+
18
+ The parent owns loading, saving, optimistic concurrency and navigation, using
19
+ its normal JSKIT `useAddEdit()`/command/resource flow. This component makes no
20
+ HTTP requests and creates no second source of connection state. Model updates
21
+ preserve other integration slots, registrations and extension values.
22
+
23
+ Provider `settingsFields` metadata supplies labelled select controls when `items`
24
+ are provided and text controls otherwise, with optional hints and placeholders.
25
+ Defaults and applicable fields come from
26
+ `getProviderSettingsSchema(provider, settings).getFieldDefinitions()`, which also
27
+ supports schemas containing custom validators. A provider may select a schema
28
+ from its current settings; changing Redshift deployment type removes fields
29
+ that belong to the other type and shows the new required inputs. The CLI rejects
30
+ mixed fields through that same schema. Clearing optional text omits it
31
+ from the serialized configuration. The parent
32
+ validates with the same provider definitions and saves the returned normalized
33
+ configuration, so forms and CLI edits apply the same defaults and errors.
34
+ For providers with a separately named credential, `apiKeyReferenceLabel`
35
+ customizes the primary reference field's label. Twilio uses **API key secret
36
+ reference** alongside the Account SID and API Key SID settings; the default
37
+ label for other providers remains **API key reference**.
38
+ `apiKeyReferenceHint` customizes the reference hint. PostHog uses it to explain
39
+ that its project token is publishable and cannot read private analytics. The
40
+ saved value still follows the same reference format as CLI configuration.
41
+
42
+ Providers with multiple `authenticationMethods` get an Authentication selector;
43
+ `authenticationLabels` and `authenticationHint` supply provider-specific copy.
44
+ `settingsFields[].authenticationMethods` controls which fields apply. Changing
45
+ mode removes the old authentication references and settings that no longer
46
+ apply. Selecting OAuth creates a new project-owned registration with editable
47
+ client fields and Env references, without replacing existing registrations. The
48
+ App registration selector can then choose an existing registration instead.
49
+ With `apiKeySecretOptional`, clearing the primary reference omits it
50
+ instead of saving an empty string. ClickHouse uses this for an empty database
51
+ password. Its separate `none` mode hides credential fields entirely. These
52
+ choices use the same shared validation as a hand-edited configuration file.
53
+
54
+ `scopesForSettings(settings)` limits the displayed permission choices. Changing
55
+ a setting retains selected scopes that still apply and removes incompatible
56
+ ones, then selects any newly required scopes. Snowflake uses this for its
57
+ optional role; clearing the role restores its required default-role scope.
58
+ Optional choices stay unselected. Slack uses this for its user and bot identities. The shared parser rejects
59
+ an incompatible permission in imported JSON; it does not silently accept a
60
+ permission that the form cannot display. Changing back does not restore removed
61
+ permissions.
62
+
63
+ Scope entries with `required: true` render as disabled checkboxes. The parent
64
+ initializes these through the provider's recommended defaults, and shared
65
+ configuration validation rejects missing required permissions. LinkedIn requires
66
+ OpenID and profile while leaving email and publishing optional.
67
+
68
+ Providers declaring multiple `oauthGrantTypes` get an **OAuth flow** selector.
69
+ `oauthGrantHint` explains the credential choice. Switching to client credentials
70
+ removes the callback field and its saved reference, filters incompatible scopes,
71
+ and changes a personal account mode to the provider's first non-personal mode.
72
+ The client authentication method follows the provider's grant-specific methods.
73
+ Switching back requires entering the callback reference again and explicitly
74
+ selecting the needed permissions. The registration's client ID and secret
75
+ reference remain editable; use the credentials belonging to the chosen flow.
76
+ `getProviderScopes` supplies the same choices used by CLI validation.
77
+ Other integrations sharing that registration are preserved; validation reports
78
+ any newly incompatible configuration instead of silently rewriting those slots.
79
+
80
+ Panels provide details, registration credentials and permissions. The default
81
+ credential field edits a **reference**, suitable for developer tooling. A host
82
+ can use the `credential` slot to provide its secret-entry control; it must save
83
+ the secret through its secret service and keep only a reference in the model.
84
+ The slot receives `reference` and `disabled`. Registrations belong to the
85
+ application and use `source: "own"`. `access` provides a place for the host's
86
+ existing permissions editor.
87
+
88
+ The component handles OAuth configurations with pre-existing slots and
89
+ registrations, API-key configurations and declared no-credential modes. The
90
+ host owns catalogue search, slot creation and persistent file editing; public
91
+ Vibe64 supplies those through its source editor. Account connection and consent
92
+ controls are still pending. The browser fixture is a focused round-trip test,
93
+ not a complete application.
94
+
95
+ Run `npm exec --no -- playwright test --config
96
+ packages/connectors-web/test/playwright.config.js` from the JSKIT checkout.
97
+ Tests cover compact, medium and expanded layouts. The config respects managed
98
+ runner base URLs and temporary authentication state when supplied.
99
+
100
+ Definitions can set `permissionsHint` when permissions represent local operation
101
+ limits rather than an OAuth consent grant, as for S3 read/write choices. These
102
+ controls and statically declared permission choices remain visible in token
103
+ mode. Dynamically discovered OAuth permission choices appear only for OAuth or
104
+ service-account authentication; retained discovery metadata does not display
105
+ OAuth consent choices in API-key mode.
106
+
107
+ `accountModesForSettings(settings)` restricts account choices using the same
108
+ helper as configuration validation. Switching a credential-scoped setting to
109
+ OAuth creates a fresh registration with that setting's supported client
110
+ authentication method. This keeps Notion REST and hosted MCP credentials
111
+ separate; previous registrations remain available for their original slots.
112
+
113
+ Switching away from OAuth discards an unused own-registration draft whose Client ID is still empty. Registrations with a Client ID or another integration referencing them are preserved. This lets a newly added OAuth provider switch to an API key without an invisible empty registration blocking configuration validation.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@jskit-ai/connectors-web",
3
+ "version": "0.1.1",
4
+ "description": "Shared Vue fields for portable application integration configuration.",
5
+ "type": "module",
6
+ "exports": {
7
+ "./client": "./src/client/index.js"
8
+ },
9
+ "dependencies": {
10
+ "@jskit-ai/connectors-core": "0.1.1"
11
+ },
12
+ "peerDependencies": {
13
+ "vue": "^3.5.13",
14
+ "vuetify": "^4.0.0"
15
+ },
16
+ "jskit": {
17
+ "kind": "runtime",
18
+ "capabilities": {
19
+ "provides": [],
20
+ "requires": []
21
+ },
22
+ "runtime": {
23
+ "server": {
24
+ "providers": []
25
+ },
26
+ "client": {
27
+ "providers": []
28
+ }
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,301 @@
1
+ <script setup>
2
+ import { computed, ref } from "vue";
3
+ import { getProviderAccountModes, getProviderAuthenticationMethods, getProviderClientAuthenticationMethods, getProviderScopes, getProviderSettingsSchema } from "@jskit-ai/connectors-core/shared/configuration";
4
+
5
+ const props = defineProps({
6
+ modelValue: { type: Object, required: true },
7
+ integrationId: { type: String, required: true },
8
+ provider: { type: Object, required: true },
9
+ fieldErrors: { type: Object, default: () => ({}) },
10
+ disabled: Boolean,
11
+ callbackUrl: { type: String, default: "" }
12
+ });
13
+ const emit = defineEmits(["update:modelValue"]);
14
+ const panels = ref(["details", "credentials"]);
15
+ const integration = computed(() => props.modelValue.integrations[props.integrationId]);
16
+ const registrationId = computed(() => integration.value?.authentication.registrationRef);
17
+ const registration = computed(() => props.modelValue.registrations[registrationId.value]);
18
+ const grantType = computed(() => registration.value?.grantType || "authorization_code");
19
+ const grantOptions = computed(() => (props.provider.oauthGrantTypes || ["authorization_code"]).map((value) => ({
20
+ value, title: { authorization_code: "User consent", client_credentials: "Service account" }[value] || value
21
+ })));
22
+ const modes = computed(() => getProviderAccountModes(props.provider, integration.value.settings || {}).filter((mode) =>
23
+ integration.value?.authentication.method !== "oauth2" || grantType.value !== "client_credentials" || mode !== "per-user"
24
+ ).map((value) => ({
25
+ value,
26
+ title: { shared: "One shared account", "per-user": "Each app user's own account", assistant: "Assistant access" }[value] || value
27
+ })));
28
+ const registrationOptions = computed(() => Object.keys(props.modelValue.registrations));
29
+ const integrationPath = computed(() => `integrations.${props.integrationId}`);
30
+ const registrationPath = computed(() => `registrations.${registrationId.value}`);
31
+ const settingProperties = computed(() => getProviderSettingsSchema(props.provider, integration.value?.settings)?.getFieldDefinitions() || {});
32
+ const authenticationOptions = computed(() => getProviderAuthenticationMethods(props.provider, integration.value.settings).map((value) => ({
33
+ value, title: props.provider.authenticationLabels?.[value] || { "api-key": "API key", "service-account": "Service account", oauth2: "OAuth", none: "No credentials" }[value] || value
34
+ })));
35
+ const visibleSettings = computed(() => (props.provider.settingsFields || []).filter((field) =>
36
+ Object.hasOwn(settingProperties.value, field.name) &&
37
+ (!field.authenticationMethods || field.authenticationMethods.includes(integration.value.authentication.method))
38
+ ));
39
+ const visibleScopes = computed(() => getProviderScopes(props.provider, integration.value.settings || {}, grantType.value, integration.value.authentication.method));
40
+ const assistantPermissions = [
41
+ { value: "ask", title: "Ask each time" }, { value: "always", title: "Always allow" }, { value: "never", title: "Never allow" }
42
+ ];
43
+
44
+ function editAssistantPolicy(changes) {
45
+ editIntegration({ assistantPolicy: { enabled: true, defaultPermission: "ask", actions: {}, ...integration.value.assistantPolicy, ...changes } });
46
+ }
47
+
48
+ function editIntegration(changes) {
49
+ const next = { ...integration.value, ...changes };
50
+ for (const key of Object.keys(changes)) if (changes[key] === undefined) delete next[key];
51
+ const registrations = { ...props.modelValue.registrations };
52
+ const previousRegistration = registrationId.value;
53
+ if (changes.authentication && next.authentication.method !== "oauth2" && previousRegistration &&
54
+ registrations[previousRegistration]?.source === "own" && registrations[previousRegistration]?.clientId === "" &&
55
+ !Object.entries(props.modelValue.integrations).some(([id, value]) =>
56
+ id !== props.integrationId && value.authentication.registrationRef === previousRegistration)) {
57
+ delete registrations[previousRegistration];
58
+ }
59
+ emit("update:modelValue", {
60
+ ...props.modelValue,
61
+ registrations,
62
+ integrations: { ...props.modelValue.integrations, [props.integrationId]: next }
63
+ });
64
+ }
65
+
66
+ function editSetting(field, value) {
67
+ const settings = { ...integration.value.settings };
68
+ if (value === undefined || value === "") delete settings[field];
69
+ else settings[field] = value;
70
+ if (typeof props.provider.settingsSchema === "function") {
71
+ const fields = getProviderSettingsSchema(props.provider, settings).getFieldDefinitions();
72
+ for (const key of Object.keys(settings)) if (!Object.hasOwn(fields, key)) delete settings[key];
73
+ }
74
+ const available = getProviderScopes(props.provider, settings, grantType.value, integration.value.authentication.method);
75
+ const scopes = integration.value.scopes.filter((value) => available.some((scope) => scope.value === value));
76
+ for (const scope of available) if (scope.required && !scopes.includes(scope.value)) scopes.push(scope.value);
77
+ const methods = getProviderAuthenticationMethods(props.provider, settings);
78
+ const credentialScope = props.provider.settingsFields?.find((item) => item.name === field)?.credentialScope;
79
+ const changedProvider = credentialScope && credentialScope(value) !== credentialScope(integration.value.settings?.[field] ?? settingProperties.value[field]?.defaultTo);
80
+ const authentication = changedProvider || !methods.includes(integration.value.authentication.method)
81
+ ? { method: methods[0] } : integration.value.authentication;
82
+ const accountModes = getProviderAccountModes(props.provider, settings);
83
+ const accountMode = accountModes.includes(integration.value.accountMode) ? integration.value.accountMode : accountModes[0];
84
+ if (authentication.method === "oauth2" && !authentication.registrationRef) {
85
+ editAuthentication("oauth2", { ...integration.value, settings, scopes, authentication, accountMode });
86
+ } else editIntegration({ settings, scopes, authentication, accountMode });
87
+ }
88
+
89
+ function editAuthentication(method, current = integration.value) {
90
+ const settings = { ...current.settings };
91
+ for (const field of props.provider.settingsFields || []) {
92
+ if (field.authenticationMethods && !field.authenticationMethods.includes(method)) delete settings[field.name];
93
+ }
94
+ const availableScopes = getProviderScopes(props.provider, settings, "authorization_code", method);
95
+ const scopes = availableScopes.filter(scope => current.scopes.includes(scope.value) || scope.required || (scope.authenticationMethods && scope.recommended)).map(scope => scope.value);
96
+ if (method === "oauth2") {
97
+ let id = props.integrationId;
98
+ let suffix = 2;
99
+ while (Object.hasOwn(props.modelValue.registrations, id)) id = `${props.integrationId}-${suffix++}`;
100
+ const prefix = id.toUpperCase().replaceAll("-", "_");
101
+ const grant = props.provider.oauthGrantTypes?.[0] || "authorization_code";
102
+ const clientAuthentication = getProviderClientAuthenticationMethods(props.provider, grant, settings)[0];
103
+ emit("update:modelValue", {
104
+ ...props.modelValue,
105
+ integrations: { ...props.modelValue.integrations, [props.integrationId]: {
106
+ ...current, scopes, authentication: { method, registrationRef: id },
107
+ ...(current.settings ? { settings } : {})
108
+ } },
109
+ registrations: { ...props.modelValue.registrations, [id]: {
110
+ source: "own", clientId: "",
111
+ ...(grant === "authorization_code" ? { callbackUrlRef: `env:${prefix}_CALLBACK_URL` } : { grantType: grant }),
112
+ ...(clientAuthentication !== "client_secret_post" ? { tokenEndpointAuthMethod: clientAuthentication } : {}),
113
+ ...(clientAuthentication !== "none" ? { clientSecretRef: `env:${prefix}_CLIENT_SECRET` } : {})
114
+ } }
115
+ });
116
+ return;
117
+ }
118
+ editIntegration({ scopes, authentication: { method }, ...(current.settings ? { settings } : {}) });
119
+ }
120
+
121
+ function editSecretReference(value) {
122
+ const authentication = { ...integration.value.authentication, secretRef: value };
123
+ if (!value && props.provider.apiKeySecretOptional) delete authentication.secretRef;
124
+ editIntegration({ authentication });
125
+ }
126
+
127
+ function editRegistration(field, value) {
128
+ emit("update:modelValue", {
129
+ ...props.modelValue,
130
+ registrations: {
131
+ ...props.modelValue.registrations,
132
+ [registrationId.value]: { ...registration.value, [field]: value }
133
+ }
134
+ });
135
+ }
136
+
137
+ function editGrantType(value) {
138
+ const nextRegistration = { ...registration.value, grantType: value };
139
+ const methods = getProviderClientAuthenticationMethods(props.provider, value, integration.value.settings || {});
140
+ if (!methods.includes(nextRegistration.tokenEndpointAuthMethod || "client_secret_post")) nextRegistration.tokenEndpointAuthMethod = methods[0];
141
+ if (nextRegistration.tokenEndpointAuthMethod === "none") delete nextRegistration.clientSecretRef;
142
+ if (value === "client_credentials") delete nextRegistration.callbackUrlRef;
143
+ const allowed = getProviderScopes(props.provider, integration.value.settings || {}, value, integration.value.authentication.method).map((scope) => scope.value);
144
+ emit("update:modelValue", {
145
+ ...props.modelValue,
146
+ registrations: { ...props.modelValue.registrations, [registrationId.value]: nextRegistration },
147
+ integrations: { ...props.modelValue.integrations, [props.integrationId]: {
148
+ ...integration.value,
149
+ accountMode: value === "client_credentials" && integration.value.accountMode === "per-user"
150
+ ? props.provider.accountModes.find((mode) => mode !== "per-user") : integration.value.accountMode,
151
+ scopes: integration.value.scopes.filter((scope) => allowed.includes(scope))
152
+ } }
153
+ });
154
+ }
155
+ </script>
156
+
157
+ <template>
158
+ <v-sheet v-if="integration" class="integration-fields" :aria-label="`${provider.name} configuration`">
159
+ <v-expansion-panels v-model="panels" multiple variant="accordion" :disabled="disabled">
160
+ <v-expansion-panel value="details" title="Details">
161
+ <v-expansion-panel-text>
162
+ <v-text-field
163
+ label="Display name" :model-value="integration.displayName || ''" :disabled="disabled"
164
+ :placeholder="provider.name" :error-messages="fieldErrors[`${integrationPath}.displayName`]"
165
+ @update:model-value="editIntegration({ displayName: $event || undefined })"
166
+ />
167
+ <v-select
168
+ label="Account used by the application" :items="modes" :model-value="integration.accountMode" :disabled="disabled"
169
+ :error-messages="fieldErrors[`${integrationPath}.accountMode`]"
170
+ @update:model-value="editIntegration({ accountMode: $event })"
171
+ />
172
+ </v-expansion-panel-text>
173
+ </v-expansion-panel>
174
+
175
+ <v-expansion-panel value="credentials" title="Credentials">
176
+ <v-expansion-panel-text>
177
+ <v-select
178
+ v-if="authenticationOptions.length > 1"
179
+ label="Authentication" :items="authenticationOptions" :model-value="integration.authentication.method" :disabled="disabled"
180
+ :hint="provider.authenticationHint" :persistent-hint="Boolean(provider.authenticationHint)"
181
+ :error-messages="fieldErrors[`${integrationPath}.authentication.method`]"
182
+ @update:model-value="editAuthentication"
183
+ />
184
+ <template v-for="field in visibleSettings" :key="field.name">
185
+ <v-autocomplete
186
+ v-if="field.searchable"
187
+ :label="field.label" :items="field.items" :disabled="disabled" auto-select-first
188
+ :model-value="integration.settings?.[field.name] ?? settingProperties[field.name]?.defaultTo"
189
+ :hint="typeof field.hint === 'function' ? field.hint(integration.settings) : field.hint" persistent-hint :error-messages="fieldErrors[`${integrationPath}.settings.${field.name}`]"
190
+ @update:model-value="editSetting(field.name, $event)"
191
+ />
192
+ <v-select
193
+ v-else-if="field.items"
194
+ :label="field.label" :items="field.items" :disabled="disabled"
195
+ :model-value="integration.settings?.[field.name] ?? settingProperties[field.name]?.defaultTo"
196
+ :hint="field.hint" persistent-hint :error-messages="fieldErrors[`${integrationPath}.settings.${field.name}`]"
197
+ @update:model-value="editSetting(field.name, $event)"
198
+ />
199
+ <v-text-field
200
+ v-else
201
+ :label="field.label" :disabled="disabled" :placeholder="field.placeholder"
202
+ :model-value="integration.settings?.[field.name] ?? settingProperties[field.name]?.defaultTo ?? ''"
203
+ :hint="field.hint" persistent-hint :error-messages="fieldErrors[`${integrationPath}.settings.${field.name}`]"
204
+ @update:model-value="editSetting(field.name, $event)"
205
+ />
206
+ </template>
207
+ <template v-if="['api-key', 'service-account'].includes(integration.authentication.method)">
208
+ <slot name="credential" :reference="integration.authentication.secretRef" :disabled="disabled">
209
+ <v-text-field
210
+ :label="integration.authentication.method === 'service-account' ? provider.serviceAccountReferenceLabel || 'Service-account credential reference' : provider.apiKeyReferenceLabel || 'API key reference'" :model-value="integration.authentication.secretRef" :disabled="disabled"
211
+ :hint="integration.authentication.method === 'service-account' ? provider.serviceAccountReferenceHint || 'Store the service credential in your environment or secret store and enter its reference here.' : provider.apiKeyReferenceHint || 'Store the API key in your environment or secret store. Enter its reference here, such as env:SERVICE_API_KEY.'"
212
+ persistent-hint :error-messages="fieldErrors[`${integrationPath}.authentication.secretRef`]"
213
+ @update:model-value="editSecretReference"
214
+ />
215
+ </slot>
216
+ </template>
217
+ <template v-else-if="integration.authentication.method === 'oauth2'">
218
+ <v-select
219
+ label="App registration" :items="registrationOptions" :model-value="registrationId" :disabled="disabled"
220
+ :error-messages="fieldErrors[`${integrationPath}.authentication.registrationRef`]"
221
+ @update:model-value="editIntegration({ authentication: { ...integration.authentication, registrationRef: $event } })"
222
+ />
223
+ <template v-if="registration?.source === 'own'">
224
+ <v-select
225
+ v-if="grantOptions.length > 1"
226
+ label="OAuth flow" :items="grantOptions" :model-value="grantType" :disabled="disabled"
227
+ :hint="provider.oauthGrantHint" :persistent-hint="Boolean(provider.oauthGrantHint)"
228
+ :error-messages="fieldErrors[`${registrationPath}.grantType`]"
229
+ @update:model-value="editGrantType"
230
+ />
231
+ <v-text-field
232
+ :label="provider.clientIdLabel || 'Client ID'" :hint="provider.clientIdHint" :persistent-hint="Boolean(provider.clientIdHint)" :model-value="registration.clientId" autocomplete="off" :disabled="disabled"
233
+ :error-messages="fieldErrors[`${registrationPath}.clientId`]"
234
+ @update:model-value="editRegistration('clientId', $event)"
235
+ />
236
+ <slot v-if="registration.tokenEndpointAuthMethod !== 'none'" name="credential" :reference="registration.clientSecretRef" :disabled="disabled">
237
+ <v-text-field
238
+ label="Client secret reference" :model-value="registration.clientSecretRef" :disabled="disabled"
239
+ hint="Use an environment reference such as env:GOOGLE_CLIENT_SECRET. Keep the value outside this file."
240
+ persistent-hint :error-messages="fieldErrors[`${registrationPath}.clientSecretRef`]"
241
+ @update:model-value="editRegistration('clientSecretRef', $event)"
242
+ />
243
+ </slot>
244
+ <v-text-field
245
+ v-if="grantType === 'authorization_code'"
246
+ hint="Reference the callback route implemented by your application and registered with the provider." persistent-hint
247
+ class="mt-4" label="Callback URL reference" :model-value="registration.callbackUrlRef" :disabled="disabled"
248
+ :error-messages="fieldErrors[`${registrationPath}.callbackUrlRef`]"
249
+ @update:model-value="editRegistration('callbackUrlRef', $event)"
250
+ />
251
+ <v-text-field v-if="callbackUrl && grantType === 'authorization_code'" label="Callback URL" :model-value="callbackUrl" readonly />
252
+ </template>
253
+ </template>
254
+ </v-expansion-panel-text>
255
+ </v-expansion-panel>
256
+
257
+ <v-expansion-panel v-if="visibleScopes.length && (provider.scopes.length || provider.permissionsHint || ['oauth2', 'service-account'].includes(integration.authentication.method))" value="permissions" title="Permissions">
258
+ <v-expansion-panel-text>
259
+ <p class="text-body-medium mb-3">{{ provider.permissionsHint || 'Choose what the application needs. The account owner approves these permissions when connecting.' }}</p>
260
+ <v-checkbox
261
+ v-for="permission in visibleScopes" :key="permission.value"
262
+ :label="permission.label" :value="permission.value" :model-value="integration.scopes" :disabled="disabled || permission.required"
263
+ hide-details @update:model-value="editIntegration({ scopes: $event })"
264
+ />
265
+ <p v-if="fieldErrors[`${integrationPath}.scopes`]" role="alert" class="text-error text-body-small mt-2">
266
+ {{ fieldErrors[`${integrationPath}.scopes`] }}
267
+ </p>
268
+ </v-expansion-panel-text>
269
+ </v-expansion-panel>
270
+ <v-expansion-panel v-if="provider.assistantActions?.length" value="assistant" title="Assistant permissions">
271
+ <v-expansion-panel-text>
272
+ <p class="text-body-medium mb-3">These choices control assistant actions for this integration. They do not grant access to the provider or replace your application's permissions.</p>
273
+ <v-switch
274
+ label="Allow assistant access" :model-value="integration.assistantPolicy?.enabled ?? true" :disabled="disabled"
275
+ :error-messages="fieldErrors[`${integrationPath}.assistantPolicy.enabled`]"
276
+ @update:model-value="editAssistantPolicy({ enabled: $event })"
277
+ />
278
+ <v-select
279
+ label="Manage all permissions" :items="assistantPermissions" :model-value="integration.assistantPolicy?.defaultPermission || 'ask'"
280
+ :disabled="disabled || integration.assistantPolicy?.enabled === false"
281
+ hint="Changing this sets every assistant action below to the same choice." persistent-hint
282
+ :error-messages="fieldErrors[`${integrationPath}.assistantPolicy.defaultPermission`]"
283
+ @update:model-value="editAssistantPolicy({ defaultPermission: $event, actions: {} })"
284
+ />
285
+ <v-select
286
+ v-for="action in provider.assistantActions" :key="action.value" :label="action.label" :items="assistantPermissions"
287
+ :model-value="integration.assistantPolicy?.actions?.[action.value] || integration.assistantPolicy?.defaultPermission || 'ask'"
288
+ :disabled="disabled || integration.assistantPolicy?.enabled === false"
289
+ :error-messages="fieldErrors[`${integrationPath}.assistantPolicy.actions.${action.value}`]"
290
+ @update:model-value="editAssistantPolicy({ actions: { ...integration.assistantPolicy?.actions, [action.value]: $event } })"
291
+ />
292
+ </v-expansion-panel-text>
293
+ </v-expansion-panel>
294
+ </v-expansion-panels>
295
+ <slot name="access" :integration="integration" />
296
+ </v-sheet>
297
+ </template>
298
+
299
+ <style scoped>
300
+ .integration-fields { min-width: 0; }
301
+ </style>
@@ -0,0 +1 @@
1
+ export { default as IntegrationConfigurationFields } from "./IntegrationConfigurationFields.vue";
@@ -0,0 +1,493 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ for (const width of [390, 820, 1440]) {
4
+ test(`Snowflake setting changes select required scopes without restoring optional choices at ${width}px`, async ({ page }) => {
5
+ await page.setViewportSize({ width, height: 1000 }); await page.goto("/");
6
+ await page.getByRole("button", { name: "Edit Snowflake", exact: true }).click();
7
+ const role = page.getByRole("textbox", { name: "Role", exact: true });
8
+ const permissions = page.getByRole("button", { name: "Permissions", exact: true });
9
+ const save = page.getByRole("button", { name: "Export configuration", exact: true });
10
+ await save.click(); const original = JSON.parse(await page.getByTestId("export").textContent());
11
+ await role.fill("VIBE64_READER"); await permissions.click();
12
+ const named = page.getByRole("checkbox", { name: "Use role VIBE64_READER (required)", exact: true });
13
+ await expect(named).toBeChecked(); await expect(named).toBeDisabled();
14
+ const offline = page.getByRole("checkbox", { name: "Keep access between visits", exact: true });
15
+ await offline.uncheck(); await role.fill("Inventory & stock");
16
+ await expect(named).toHaveCount(0); await expect(offline).not.toBeChecked();
17
+ await expect(page.getByRole("checkbox", { name: "Use role Inventory & stock (required)", exact: true })).toBeChecked();
18
+ await save.click(); const namedFile = JSON.parse(await page.getByTestId("export").textContent());
19
+ expect(namedFile.integrations.snowflake.scopes).toEqual(["session:role-encoded:Inventory%20%26%20stock"]);
20
+ expect(namedFile.integrations.snowflake.extensions).toEqual({ keep: "account" });
21
+ expect(namedFile.integrations.calendar).toEqual(original.integrations.calendar);
22
+ await page.getByRole("button", { name: "Leave configuration" }).click(); await page.getByRole("button", { name: "Return to configuration" }).click();
23
+ await expect(role).toHaveValue("Inventory & stock");
24
+ await page.getByLabel("Lock form").check(); await expect(role).toBeDisabled(); await page.getByLabel("Lock form").uncheck();
25
+ await role.fill(""); await save.click(); const defaults = JSON.parse(await page.getByTestId("export").textContent());
26
+ expect(defaults.integrations.snowflake.scopes).toEqual(["refresh_token"]); expect(defaults.integrations.snowflake.settings.role).toBeUndefined();
27
+ if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
28
+ const requiredOffline = page.getByRole("checkbox", { name: "Keep access between visits (required)", exact: true });
29
+ await expect(requiredOffline).toBeChecked(); await expect(requiredOffline).toBeDisabled();
30
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(namedFile)); await page.getByRole("button", { name: "Import configuration", exact: true }).click();
31
+ await expect(role).toHaveValue("Inventory & stock"); await expect(offline).not.toBeChecked();
32
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
33
+ });
34
+
35
+ test(`Databricks OAuth flow changes keep file fields, permissions and account identity consistent at ${width}px`, async ({ page }) => {
36
+ await page.setViewportSize({ width, height: 1000 });
37
+ await page.goto("/");
38
+ await page.getByRole("button", { name: "Edit Databricks", exact: true }).click();
39
+ const flow = page.getByRole("combobox", { name: "OAuth flow", exact: true });
40
+ const account = page.getByRole("combobox", { name: "Account used by the application", exact: true });
41
+ const callback = page.getByRole("textbox", { name: "Callback URL reference", exact: true });
42
+ const client = page.getByRole("textbox", { name: "Client ID", exact: true });
43
+ const save = page.getByRole("button", { name: "Export configuration", exact: true });
44
+ const permissions = page.getByRole("button", { name: "Permissions", exact: true });
45
+ await expect(flow).toHaveValue("User consent"); await expect(account).toHaveValue("Each app user's own account");
46
+ await expect(callback).toHaveValue("env:DATABRICKS_CALLBACK");
47
+ await save.click(); const initial = JSON.parse(await page.getByTestId("export").textContent());
48
+ await page.getByText("User consent", { exact: true }).click();
49
+ await page.getByRole("option", { name: "Service account", exact: true }).click();
50
+ await expect(callback).toHaveCount(0); await expect(account).toHaveValue("One shared account");
51
+ await page.getByText("One shared account", { exact: true }).click();
52
+ await expect(page.getByRole("option", { name: "Each app user's own account", exact: true })).toHaveCount(0);
53
+ await page.keyboard.press("Escape");
54
+ await client.fill("service-client");
55
+ await permissions.click();
56
+ const jobs = page.getByRole("checkbox", { name: "Jobs API access for the service account", exact: true });
57
+ await jobs.check();
58
+ await expect(page.getByRole("checkbox", { name: "Keep user access between visits", exact: true })).toHaveCount(0);
59
+ await save.click(); const machine = JSON.parse(await page.getByTestId("export").textContent());
60
+ expect(machine.registrations.databricks).toEqual({ source: "own", clientId: "service-client", clientSecretRef: "env:DATABRICKS_SECRET",
61
+ grantType: "client_credentials", tokenEndpointAuthMethod: "client_secret_basic" });
62
+ expect(machine.integrations.databricks.scopes).toEqual(["all-apis", "jobs"]);
63
+ expect(machine.integrations.databricks.accountMode).toBe("shared");
64
+ expect(machine.integrations.databricks.extensions).toEqual({ keep: "workspace" });
65
+ expect(machine.integrations.calendar).toEqual(initial.integrations.calendar);
66
+ expect(machine.registrations.google).toEqual(initial.registrations.google);
67
+ await page.getByLabel("Lock form").check(); await expect(flow).toBeDisabled(); await expect(jobs).toBeDisabled();
68
+ await page.getByLabel("Lock form").uncheck();
69
+ await page.getByRole("button", { name: "Leave configuration" }).click();
70
+ await page.getByRole("button", { name: "Return to configuration" }).click();
71
+ await expect(flow).toHaveValue("Service account"); await expect(callback).toHaveCount(0);
72
+ await page.getByText("Service account", { exact: true }).click();
73
+ await page.getByRole("option", { name: "User consent", exact: true }).click();
74
+ await expect(callback).toHaveValue("");
75
+ await save.click();
76
+ await expect(page.locator(".v-input").filter({ has: callback }).getByText("This value is required.", { exact: true })).toBeVisible();
77
+ await callback.fill("env:USER_CALLBACK"); await client.fill("user-client");
78
+ if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
79
+ await expect(jobs).toHaveCount(0);
80
+ await page.getByRole("checkbox", { name: "Keep user access between visits", exact: true }).check();
81
+ await save.click(); const user = JSON.parse(await page.getByTestId("export").textContent());
82
+ expect(user.registrations.databricks.tokenEndpointAuthMethod).toBe("client_secret_post");
83
+ expect(user.integrations.databricks.scopes).toEqual(["all-apis", "offline_access"]);
84
+ const invalid = structuredClone(machine); invalid.integrations.databricks.accountMode = "per-user";
85
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(invalid));
86
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
87
+ await expect(page.getByText("A service account cannot connect as each app user.", { exact: true })).toBeVisible();
88
+ await expect(flow).toHaveValue("User consent");
89
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(machine));
90
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
91
+ await expect(flow).toHaveValue("Service account"); await expect(callback).toHaveCount(0);
92
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
93
+ });
94
+
95
+ test(`Redshift deployment changes share CLI validation and remove inactive fields at ${width}px`, async ({ page }) => {
96
+ await page.setViewportSize({ width, height: 1000 });
97
+ await page.goto("/");
98
+ await page.getByRole("button", { name: "Edit Redshift", exact: true }).click();
99
+ const mode = page.getByRole("combobox", { name: "Deployment type", exact: true });
100
+ const workgroup = page.getByRole("textbox", { name: "Workgroup name", exact: true });
101
+ const cluster = page.getByRole("textbox", { name: "Cluster identifier", exact: true });
102
+ const database = page.getByRole("textbox", { name: "Database", exact: true });
103
+ const user = page.getByRole("textbox", { name: "Database user (optional)", exact: true });
104
+ const save = page.getByRole("button", { name: "Export configuration", exact: true });
105
+ await expect(mode).toHaveValue("Serverless");
106
+ await expect(workgroup).toHaveValue("analytics"); await expect(cluster).toHaveCount(0); await expect(user).toHaveCount(0);
107
+ await save.click();
108
+ await expect(page.getByTestId("export")).toContainText('"workgroup": "analytics"');
109
+ const original = JSON.parse(await page.getByTestId("export").textContent());
110
+ await page.getByText("Serverless", { exact: true }).click();
111
+ await page.getByRole("option", { name: "Provisioned cluster", exact: true }).click();
112
+ await expect(workgroup).toHaveCount(0); await expect(cluster).toHaveValue(""); await expect(database).toHaveValue("dev");
113
+ await save.click();
114
+ await expect(page.getByTestId("export")).toHaveText(JSON.stringify(original, null, 2));
115
+ await cluster.fill("analytics-cluster"); await user.fill("report_reader"); await save.click();
116
+ await expect(page.getByTestId("export")).toContainText('"databaseUser": "report_reader"');
117
+ const provisioned = JSON.parse(await page.getByTestId("export").textContent());
118
+ expect(provisioned.integrations.redshift.settings).toEqual({ deploymentType: "provisioned", region: "us-east-1", database: "dev", accessKeyIdRef: "env:AWS_ACCESS_KEY_ID", clusterIdentifier: "analytics-cluster", databaseUser: "report_reader" });
119
+ expect(provisioned.integrations.redshift.extensions).toEqual({ keep: "redshift" });
120
+ expect(provisioned.integrations.calendar).toEqual(original.integrations.calendar);
121
+ await page.getByRole("button", { name: "Leave configuration", exact: true }).click();
122
+ await page.getByRole("button", { name: "Return to configuration", exact: true }).click();
123
+ await expect(mode).toHaveValue("Provisioned cluster"); await expect(cluster).toHaveValue("analytics-cluster");
124
+ await page.getByLabel("Lock form").check(); await expect(mode).toBeDisabled(); await page.getByLabel("Lock form").uncheck();
125
+ await page.getByText("Provisioned cluster", { exact: true }).click();
126
+ await page.getByRole("option", { name: "Serverless", exact: true }).click();
127
+ await expect(cluster).toHaveCount(0); await expect(user).toHaveCount(0); await expect(workgroup).toHaveValue("");
128
+ await workgroup.fill("restored-group"); await expect(workgroup).toHaveValue("restored-group"); await save.click();
129
+ await expect(page.getByTestId("export")).toContainText('"workgroup": "restored-group"');
130
+ const restored = JSON.parse(await page.getByTestId("export").textContent());
131
+ expect(restored.integrations.redshift.settings).toEqual({ deploymentType: "serverless", region: "us-east-1", database: "dev", accessKeyIdRef: "env:AWS_ACCESS_KEY_ID", workgroup: "restored-group" });
132
+ const mixed = structuredClone(restored); mixed.integrations.redshift.settings.clusterIdentifier = "mixed";
133
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(mixed));
134
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
135
+ await expect(workgroup).toHaveValue("restored-group"); await expect(cluster).toHaveCount(0);
136
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
137
+ await expect(page.locator(".v-progress-circular")).toHaveCount(0);
138
+ });
139
+ test(`Slack actor changes retain applicable permissions and reject invalid imports at ${width}px`, async ({ page }) => {
140
+ await page.setViewportSize({ width, height: 1000 });
141
+ await page.goto("/");
142
+ await page.getByRole("button", { name: "Edit Slack", exact: true }).click();
143
+ const actor = page.getByRole("combobox", { name: "Act in Slack as", exact: true });
144
+ const permissions = page.getByRole("button", { name: "Permissions", exact: true });
145
+ const save = page.getByRole("button", { name: "Export configuration", exact: true });
146
+ const read = page.getByRole("checkbox", { name: "View basic information about public channels in a workspace", exact: true });
147
+ const profile = page.getByRole("checkbox", { name: "Edit a user's profile information and status", exact: true });
148
+ const join = page.getByRole("checkbox", { name: "Join public channels in a workspace", exact: true });
149
+ if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
150
+ await expect(page.locator(".v-expansion-panel-text").getByRole("checkbox")).toHaveCount(52);
151
+ await expect(read).toBeChecked();
152
+ await profile.check();
153
+ await page.getByText("Connected user", { exact: true }).click();
154
+ await page.getByRole("option", { name: "Installed bot", exact: true }).click();
155
+ await expect(page.locator(".v-expansion-panel-text").getByRole("checkbox")).toHaveCount(49);
156
+ await expect(profile).toHaveCount(0);
157
+ await expect(read).toBeChecked();
158
+ await join.check();
159
+ await save.click();
160
+ const bot = JSON.parse(await page.getByTestId("export").textContent());
161
+ expect(bot.integrations.slack.scopes).toEqual(["channels:read", "channels:join"]);
162
+ expect(bot.integrations.slack.settings).toEqual({ actor: "bot" });
163
+ expect(bot.integrations.slack.extensions).toEqual({ keep: "workspace" });
164
+ await page.getByLabel("Lock form").check();
165
+ await expect(actor).toBeDisabled();
166
+ await expect(join).toBeDisabled();
167
+ await page.getByLabel("Lock form").uncheck();
168
+ await page.getByRole("button", { name: "Leave configuration" }).click();
169
+ await page.getByRole("button", { name: "Return to configuration" }).click();
170
+ await expect(actor).toHaveValue("Installed bot");
171
+ await page.getByText("Installed bot", { exact: true }).click();
172
+ await page.getByRole("option", { name: "Connected user", exact: true }).click();
173
+ await save.click();
174
+ const user = JSON.parse(await page.getByTestId("export").textContent());
175
+ expect(user.integrations.slack.scopes).toEqual(["channels:read"]);
176
+ expect(user.integrations.calendar).toEqual(bot.integrations.calendar);
177
+ const invalid = structuredClone(bot);
178
+ invalid.integrations.slack.scopes.push("users.profile:write");
179
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(invalid));
180
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
181
+ if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
182
+ await expect(page.getByText("A selected permission is not supported by this provider configuration.", { exact: true })).toBeVisible();
183
+ await expect(actor).toHaveValue("Connected user");
184
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(bot));
185
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
186
+ await expect(actor).toHaveValue("Installed bot");
187
+ if (await permissions.getAttribute("aria-expanded") !== "true") await permissions.click();
188
+ await expect(join).toBeChecked();
189
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
190
+ });
191
+
192
+ test(`ClickHouse authentication modes preserve portable configuration at ${width}px`, async ({ page }) => {
193
+ await page.setViewportSize({ width, height: 1000 });
194
+ await page.goto("/");
195
+ await page.getByRole("button", { name: "Edit ClickHouse", exact: true }).click();
196
+ const password = page.getByRole("textbox", { name: "Password reference (optional)", exact: true });
197
+ const username = page.getByRole("textbox", { name: "Username (optional)", exact: true });
198
+ const endpoint = page.getByRole("textbox", { name: "HTTP Interface URL", exact: true });
199
+ const authentication = page.getByRole("combobox", { name: "Authentication", exact: true });
200
+ const save = page.getByRole("button", { name: "Export configuration", exact: true });
201
+ await expect(username).toHaveValue("reader");
202
+ await password.fill("raw-password");
203
+ await save.click();
204
+ await expect(page.getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
205
+ await password.fill("");
206
+ await save.click();
207
+ const value = JSON.parse(await page.getByTestId("export").textContent());
208
+ expect(value.integrations.warehouse.authentication).toEqual({ method: "api-key" });
209
+ await page.getByText("Username and password", { exact: true }).click();
210
+ await page.getByRole("option", { name: "No credentials", exact: true }).click();
211
+ await expect(password).toHaveCount(0);
212
+ await expect(username).toHaveCount(0);
213
+ await save.click();
214
+ const anonymous = JSON.parse(await page.getByTestId("export").textContent());
215
+ expect(anonymous.integrations.warehouse.authentication).toEqual({ method: "none" });
216
+ expect(anonymous.integrations.warehouse.settings).toEqual({ httpUrl: "https://warehouse.example:8443/query/" });
217
+ expect(anonymous.integrations.warehouse.extensions).toEqual({ keep: "database" });
218
+ expect(anonymous.integrations.calendar).toEqual(value.integrations.calendar);
219
+ await page.getByLabel("Lock form").check();
220
+ await expect(authentication).toBeDisabled();
221
+ await expect(endpoint).toBeDisabled();
222
+ await page.getByLabel("Lock form").uncheck();
223
+ await page.getByRole("button", { name: "Leave configuration" }).click();
224
+ await page.getByRole("button", { name: "Return to configuration" }).click();
225
+ await expect(page.getByText("No credentials", { exact: true })).toBeVisible();
226
+ await expect(endpoint).toHaveValue("https://warehouse.example:8443/query/");
227
+ await page.getByText("No credentials", { exact: true }).click();
228
+ await page.getByRole("option", { name: "Username and password", exact: true }).click();
229
+ await expect(username).toHaveValue("");
230
+ await expect(password).toHaveValue("");
231
+ value.integrations.warehouse.authentication.secretRef = "env:CLI_PASSWORD";
232
+ value.integrations.warehouse.settings.username = "cli-reader";
233
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(value));
234
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
235
+ await expect(username).toHaveValue("cli-reader");
236
+ await expect(password).toHaveValue("env:CLI_PASSWORD");
237
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
238
+ });
239
+
240
+ test(`configuration remains portable and usable at ${width}px`, async ({ page }) => {
241
+ await page.setViewportSize({ width, height: 1000 });
242
+ await page.goto("/");
243
+ await page.getByLabel("Display name").fill("My calendar");
244
+ await page.getByLabel("Client ID", { exact: true }).fill("updated-client");
245
+ await page.getByRole("button", { name: "Permissions", exact: true }).click();
246
+ await page.getByLabel("Read events", { exact: true }).uncheck();
247
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
248
+ await expect(page.getByTestId("export")).toContainText("updated-client");
249
+ const value = JSON.parse(await page.getByTestId("export").textContent());
250
+ expect(value.integrations.calendar.displayName).toBe("My calendar");
251
+ expect(value.integrations.calendar.extensions.businessRule).toBe("keep-me");
252
+ expect(value.integrations.calendar.scopes).toHaveLength(1);
253
+
254
+ await page.getByRole("button", { name: "Leave configuration" }).click();
255
+ await page.getByRole("button", { name: "Return to configuration" }).click();
256
+ await expect(page.getByLabel("Display name")).toHaveValue("My calendar");
257
+
258
+ const obsolete = structuredClone(value);
259
+ obsolete.registrations.google.source = "managed";
260
+ obsolete.registrations.google.clientId = "must-not-be-imported";
261
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(obsolete));
262
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
263
+ await expect(page.getByLabel("Client ID", { exact: true })).toHaveValue("updated-client");
264
+ await expect(page.getByLabel("Managed registration", { exact: true })).toHaveCount(0);
265
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
266
+ expect(JSON.parse(await page.getByTestId("export").textContent()).registrations.google.source).toBe("own");
267
+
268
+ value.registrations.google.clientId = "written-from-cli";
269
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(value));
270
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
271
+ await expect(page.getByLabel("Client ID", { exact: true })).toHaveValue("written-from-cli");
272
+
273
+ await page.getByLabel("Display name").fill("");
274
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
275
+ expect(JSON.parse(await page.getByTestId("export").textContent()).integrations.calendar).not.toHaveProperty("displayName");
276
+ await page.getByLabel("Lock form").check();
277
+ await expect(page.getByLabel("Client ID", { exact: true })).toBeDisabled();
278
+ await page.getByLabel("Lock form").uncheck();
279
+
280
+ await page.getByLabel("Client secret reference").fill("an-actual-secret-is-not-a-reference");
281
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
282
+ await expect(page.getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
283
+ await page.getByLabel("Client secret reference").fill("env:GOOGLE_SECRET");
284
+ await page.getByLabel("Callback URL reference").fill("https://callback.example.test/oauth");
285
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
286
+ await expect(page.locator(".v-input").filter({ has: page.getByLabel("Callback URL reference") }).getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
287
+ await page.getByLabel("Callback URL reference").fill("env:CALLBACK");
288
+ await page.getByRole("button", { name: "Edit Mailgun", exact: true }).click();
289
+ await expect(page.getByText("United States (api.mailgun.net)", { exact: true })).toBeVisible();
290
+ const region = page.getByRole("combobox", { name: "API region", exact: true });
291
+ await page.getByText("United States (api.mailgun.net)", { exact: true }).click();
292
+ await page.getByRole("option", { name: "European Union (api.eu.mailgun.net)", exact: true }).click();
293
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
294
+ const regional = JSON.parse(await page.getByTestId("export").textContent());
295
+ expect(regional.integrations.mail.settings).toEqual({ region: "eu" });
296
+ expect(regional.integrations.mail.extensions).toEqual({ label: "preserve" });
297
+ await page.getByLabel("Lock form").check();
298
+ await expect(region).toBeDisabled();
299
+ await page.getByLabel("Lock form").uncheck();
300
+ await page.getByRole("button", { name: "Leave configuration" }).click();
301
+ await page.getByRole("button", { name: "Return to configuration" }).click();
302
+ await expect(page.getByText("European Union (api.eu.mailgun.net)", { exact: true })).toBeVisible();
303
+ await page.getByRole("button", { name: "Edit Algolia", exact: true }).click();
304
+ const applicationId = page.getByRole("textbox", { name: "Application ID", exact: true });
305
+ const publicKey = page.getByRole("textbox", { name: "Public API key reference (optional)", exact: true });
306
+ await expect(applicationId).toHaveValue("ORIGINALAPP");
307
+ await applicationId.fill("app.invalid");
308
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
309
+ await expect(page.getByText("Enter the application ID using letters and digits.", { exact: true })).toBeVisible();
310
+ await applicationId.fill("");
311
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
312
+ await expect(page.locator(".v-input--error")).toHaveCount(1);
313
+ await applicationId.fill("UPDATEDAPP");
314
+ await publicKey.fill("raw-search-key");
315
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
316
+ await expect(page.getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
317
+ await publicKey.fill("env:ALGOLIA_SEARCH_KEY");
318
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
319
+ const search = JSON.parse(await page.getByTestId("export").textContent());
320
+ expect(search.integrations.search.settings).toEqual({ applicationId: "UPDATEDAPP", publicApiKeyRef: "env:ALGOLIA_SEARCH_KEY" });
321
+ expect(search.integrations.search.authentication.secretRef).toBe("env:ALGOLIA_BACKEND_KEY");
322
+ expect(search.integrations.search.extensions).toEqual({ keep: true });
323
+ await page.getByLabel("Lock form").check();
324
+ await expect(applicationId).toBeDisabled();
325
+ await expect(publicKey).toBeDisabled();
326
+ await page.getByLabel("Lock form").uncheck();
327
+ await page.getByRole("button", { name: "Leave configuration" }).click();
328
+ await page.getByRole("button", { name: "Return to configuration" }).click();
329
+ await expect(publicKey).toHaveValue("env:ALGOLIA_SEARCH_KEY");
330
+ await publicKey.fill("");
331
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
332
+ expect(JSON.parse(await page.getByTestId("export").textContent()).integrations.search.settings).toEqual({ applicationId: "UPDATEDAPP" });
333
+ search.integrations.search.settings.applicationId = "CLIAPP";
334
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(search));
335
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
336
+ await expect(applicationId).toHaveValue("CLIAPP");
337
+ await expect(publicKey).toHaveValue("env:ALGOLIA_SEARCH_KEY");
338
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
339
+ const smallButtons = await page.getByRole("button").evaluateAll((buttons) => buttons.filter((button) => {
340
+ const box = button.getBoundingClientRect();
341
+ return box.width > 0 && box.height > 0 && (box.width < 48 || box.height < 48);
342
+ }).map((button) => button.textContent));
343
+ expect(smallButtons).toEqual([]);
344
+ });
345
+ }
346
+
347
+
348
+ test("Contentful delivery fields preserve UI and CLI configuration", async ({ page }) => {
349
+ await page.setViewportSize({ width: 390, height: 1000 });
350
+ await page.goto("/");
351
+ await page.getByRole("button", { name: "Edit Contentful", exact: true }).click();
352
+ const space = page.getByRole("textbox", { name: "Space ID", exact: true });
353
+ const environment = page.getByRole("textbox", { name: "Environment ID", exact: true });
354
+ const token = page.getByRole("textbox", { name: "Content Delivery API token reference", exact: true });
355
+ const save = page.getByRole("button", { name: "Export configuration", exact: true });
356
+ await expect(space).toHaveValue("original-space");
357
+ await expect(environment).toHaveValue("master");
358
+ await space.fill("");
359
+ await save.click();
360
+ await expect(page.locator(".v-input--error")).toHaveCount(1);
361
+ await space.fill("published-space");
362
+ await environment.fill("release-2");
363
+ await token.fill("raw-token");
364
+ await save.click();
365
+ await expect(page.getByText("Use a reference such as env:VARIABLE_NAME.", { exact: true })).toBeVisible();
366
+ await token.fill("env:CONTENTFUL_ROTATED_TOKEN");
367
+ await page.getByText("United States", { exact: true }).click();
368
+ await page.getByRole("option", { name: "Europe", exact: true }).click();
369
+ await save.click();
370
+ const exported = JSON.parse(await page.getByTestId("export").textContent());
371
+ expect(exported.integrations.content.settings).toEqual({ spaceId: "published-space", environmentId: "release-2", region: "eu" });
372
+ expect(exported.integrations.content.authentication.secretRef).toBe("env:CONTENTFUL_ROTATED_TOKEN");
373
+ await page.getByRole("button", { name: "Leave configuration" }).click();
374
+ await page.getByRole("button", { name: "Return to configuration" }).click();
375
+ await expect(space).toHaveValue("published-space");
376
+ await expect(environment).toHaveValue("release-2");
377
+ await expect(token).toHaveValue("env:CONTENTFUL_ROTATED_TOKEN");
378
+ exported.integrations.content.settings.spaceId = "cli-space";
379
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(exported));
380
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
381
+ await expect(space).toHaveValue("cli-space");
382
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
383
+ });
384
+
385
+
386
+ test("Analytics public setting round-trips without credentials", async ({ page }) => {
387
+ await page.setViewportSize({ width: 390, height: 1000 });
388
+ await page.goto("/");
389
+ await page.getByRole("button", { name: "Edit Analytics", exact: true }).click();
390
+ const measurement = page.getByRole("textbox", { name: "Measurement ID", exact: true });
391
+ const save = page.getByRole("button", { name: "Export configuration", exact: true });
392
+ await expect(measurement).toHaveValue("G-ORIGINAL1");
393
+ await expect(page.getByText("Public: this value will be visible in your published application. Copy it from your GA4 web stream.", { exact: true })).toBeVisible();
394
+ await expect(page.getByRole("textbox", { name: /secret|callback|client id|API key/i })).toHaveCount(0);
395
+ await measurement.fill("");
396
+ await save.click();
397
+ await expect(page.locator(".v-input--error")).toHaveCount(1);
398
+ await measurement.fill("UA-123-1");
399
+ await save.click();
400
+ await expect(page.locator(".v-input--error")).toHaveCount(1);
401
+ await measurement.fill("G-WEBSITE123");
402
+ await save.click();
403
+ const exported = JSON.parse(await page.getByTestId("export").textContent());
404
+ expect(exported.integrations.analytics).toMatchObject({
405
+ provider: "google-analytics", accountMode: "shared", scopes: [],
406
+ authentication: { method: "none" }, settings: { measurementId: "G-WEBSITE123" }
407
+ });
408
+ expect(exported.integrations.analytics.authentication).toEqual({ method: "none" });
409
+ await page.getByRole("button", { name: "Leave configuration" }).click();
410
+ await page.getByRole("button", { name: "Return to configuration" }).click();
411
+ await expect(measurement).toHaveValue("G-WEBSITE123");
412
+ exported.integrations.analytics.settings.measurementId = "G-CLI123";
413
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(exported));
414
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
415
+ await expect(measurement).toHaveValue("G-CLI123");
416
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
417
+ });
418
+
419
+
420
+ test("Drive captured permissions retain required file access and optional choices", async ({ page }) => {
421
+ await page.goto("/");
422
+ await page.getByRole("button", { name: "Edit Drive", exact: true }).click();
423
+ const required = page.getByRole("checkbox", { name: "Manage files selected for this application", exact: true });
424
+ if (!await required.isVisible()) await page.getByRole("button", { name: "Permissions", exact: true }).click();
425
+ await expect(required).toBeChecked();
426
+ await expect(required).toBeDisabled();
427
+ for (const name of ["Read and download files", "Manage application data", "Manage this application’s Drive folder data"]) {
428
+ const checkbox = page.getByRole("checkbox", { name, exact: true });
429
+ await expect(checkbox).toBeChecked();
430
+ await checkbox.uncheck();
431
+ }
432
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
433
+ expect(JSON.parse(await page.getByTestId("export").textContent()).integrations.drive.scopes)
434
+ .toEqual(["https://www.googleapis.com/auth/drive.file"]);
435
+ await page.getByRole("button", { name: "Leave configuration" }).click();
436
+ await page.getByRole("button", { name: "Return to configuration" }).click();
437
+ if (!await required.isVisible()) await page.getByRole("button", { name: "Permissions", exact: true }).click();
438
+ await expect(required).toBeChecked();
439
+ await expect(page.getByRole("checkbox", { name: "Read and download files", exact: true })).not.toBeChecked();
440
+ });
441
+
442
+ test("Gmail captured permissions allow optional sending permissions to be removed", async ({ page }) => {
443
+ await page.goto("/");
444
+ await page.getByRole("button", { name: "Edit Gmail", exact: true }).click();
445
+ const required = page.getByRole("checkbox", { name: "Read messages", exact: true });
446
+ if (!await required.isVisible()) await page.getByRole("button", { name: "Permissions", exact: true }).click();
447
+ await expect(required).toBeChecked();
448
+ await expect(required).toBeDisabled();
449
+ for (const name of ["Send messages", "Manage drafts and send messages", "Read and modify messages"]) {
450
+ const checkbox = page.getByRole("checkbox", { name, exact: true });
451
+ await expect(checkbox).toBeChecked();
452
+ await checkbox.uncheck();
453
+ }
454
+ await page.getByRole("button", { name: "Export configuration", exact: true }).click();
455
+ expect(JSON.parse(await page.getByTestId("export").textContent()).integrations.gmail.scopes)
456
+ .toEqual(["https://www.googleapis.com/auth/gmail.readonly"]);
457
+ await page.getByRole("button", { name: "Leave configuration" }).click();
458
+ await page.getByRole("button", { name: "Return to configuration" }).click();
459
+ if (!await required.isVisible()) await page.getByRole("button", { name: "Permissions", exact: true }).click();
460
+ await expect(required).toBeChecked();
461
+ await expect(page.getByRole("checkbox", { name: "Send messages", exact: true })).not.toBeChecked();
462
+ });
463
+
464
+
465
+ test("BigQuery query project validates and round-trips through CLI configuration", async ({ page }) => {
466
+ await page.setViewportSize({ width: 390, height: 1000 });
467
+ await page.goto("/");
468
+ await page.getByRole("button", { name: "Edit BigQuery", exact: true }).click();
469
+ const project = page.getByRole("textbox", { name: "Google Cloud project ID", exact: true });
470
+ const save = page.getByRole("button", { name: "Export configuration", exact: true });
471
+ await expect(project).toHaveValue("query-project");
472
+ for (const value of ["", "123456789", "https://example.com"]) {
473
+ await project.fill(value);
474
+ await save.click();
475
+ await expect(page.locator(".v-input--error")).toHaveCount(1);
476
+ }
477
+ await project.fill("billing-project");
478
+ await save.click();
479
+ const exported = JSON.parse(await page.getByTestId("export").textContent());
480
+ expect(exported.integrations.bigquery).toMatchObject({
481
+ settings: { projectId: "billing-project" },
482
+ authentication: { method: "oauth2", registrationRef: "google" },
483
+ scopes: ["https://www.googleapis.com/auth/bigquery"]
484
+ });
485
+ await page.getByRole("button", { name: "Leave configuration" }).click();
486
+ await page.getByRole("button", { name: "Return to configuration" }).click();
487
+ await expect(project).toHaveValue("billing-project");
488
+ exported.integrations.bigquery.settings.projectId = "cli-project";
489
+ await page.getByLabel("Import configuration JSON").fill(JSON.stringify(exported));
490
+ await page.getByRole("button", { name: "Import configuration", exact: true }).click();
491
+ await expect(project).toHaveValue("cli-project");
492
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
493
+ });
@@ -0,0 +1,126 @@
1
+ <script setup>
2
+ import { computed, ref } from "vue";
3
+ import { googleAnalyticsDefinition, googleDriveDefinition, gmailDefinition, bigqueryDefinition } from "../../../connectors-catalog/src/shared/google.js";
4
+ import { IntegrationConfigurationFields } from "../../src/client/index.js";
5
+ import { googleCalendarDefinition } from "../../../connector-google-calendar/src/shared/definition.js";
6
+ import { mailgunDefinition, contentfulDefinition } from "../../../connectors-catalog/src/shared/tokens.js";
7
+ import { algoliaDefinition } from "../../../connectors-catalog/src/shared/algolia.js";
8
+ import { clickhouseDefinition } from "../../../connectors-catalog/src/shared/clickhouse.js";
9
+ import { slackDefinition } from "../../../connectors-catalog/src/shared/slack.js";
10
+ import { amazonRedshiftDefinition } from "../../../connectors-catalog/src/shared/amazon-redshift.js";
11
+ import { snowflakeDefinition } from "../../../connectors-catalog/src/shared/snowflake.js";
12
+ import { databricksDefinition } from "../../../connectors-catalog/src/shared/databricks.js";
13
+ import { parseIntegrationConfiguration, validateIntegrationConfiguration } from "../../../connectors-core/src/shared/configuration.js";
14
+
15
+ const configuration = ref({
16
+ schemaVersion: 1,
17
+ integrations: { bigquery: {
18
+ provider: "bigquery", accountMode: "shared",
19
+ scopes: bigqueryDefinition.scopes.filter((scope) => scope.recommended).map((scope) => scope.value),
20
+ authentication: { method: "oauth2", registrationRef: "google" }, settings: { projectId: "query-project" }
21
+ }, gmail: {
22
+ provider: "gmail", accountMode: "shared",
23
+ scopes: gmailDefinition.scopes.filter((scope) => scope.recommended).map((scope) => scope.value),
24
+ authentication: { method: "oauth2", registrationRef: "google" }
25
+ }, drive: {
26
+ provider: "google-drive", accountMode: "shared",
27
+ scopes: googleDriveDefinition.scopes.filter((scope) => scope.recommended).map((scope) => scope.value),
28
+ authentication: { method: "oauth2", registrationRef: "google" }
29
+ }, analytics: {
30
+ provider: "google-analytics", accountMode: "shared", scopes: [],
31
+ authentication: { method: "none" }, settings: { measurementId: "G-ORIGINAL1" }
32
+ }, content: {
33
+ provider: "contentful", accountMode: "shared", scopes: [], settings: { spaceId: "original-space" },
34
+ authentication: { method: "api-key", secretRef: "env:CONTENTFUL_DELIVERY_TOKEN" }
35
+ }, calendar: {
36
+ provider: "google-calendar", displayName: "Team calendar", accountMode: "per-user",
37
+ scopes: googleCalendarDefinition.scopes.filter((scope) => scope.recommended).map((scope) => scope.value),
38
+ authentication: { method: "oauth2", registrationRef: "google" },
39
+ extensions: { businessRule: "keep-me" }
40
+ }, mail: {
41
+ provider: "mailgun", accountMode: "shared", scopes: [],
42
+ authentication: { method: "api-key", secretRef: "env:MAILGUN_KEY" }, extensions: { label: "preserve" }
43
+ }, search: {
44
+ provider: "algolia", accountMode: "shared", scopes: [],
45
+ authentication: { method: "api-key", secretRef: "env:ALGOLIA_BACKEND_KEY" },
46
+ settings: { applicationId: "ORIGINALAPP" }, extensions: { keep: true }
47
+ }, warehouse: {
48
+ provider: "clickhouse", accountMode: "shared", scopes: [],
49
+ authentication: { method: "api-key", secretRef: "env:CLICKHOUSE_PASSWORD" },
50
+ settings: { httpUrl: "https://warehouse.example:8443/query/", username: "reader" }, extensions: { keep: "database" }
51
+ }, slack: {
52
+ provider: "slack", accountMode: "per-user", scopes: ["channels:read"], settings: { actor: "user" },
53
+ authentication: { method: "oauth2", registrationRef: "slack" }, extensions: { keep: "workspace" }
54
+ }, redshift: {
55
+ provider: "amazon-redshift", accountMode: "shared", scopes: [],
56
+ authentication: { method: "api-key", secretRef: "env:AWS_SECRET_ACCESS_KEY" },
57
+ settings: { workgroup: "analytics", database: "dev", accessKeyIdRef: "env:AWS_ACCESS_KEY_ID" }, extensions: { keep: "redshift" }
58
+ }, snowflake: {
59
+ provider: "snowflake", accountMode: "per-user", scopes: ["refresh_token"],
60
+ authentication: { method: "oauth2", registrationRef: "snowflake" },
61
+ settings: { accountUrl: "https://myorg-myaccount.snowflakecomputing.com" }, extensions: { keep: "account" }
62
+ }, databricks: {
63
+ provider: "databricks", accountMode: "per-user", scopes: ["all-apis", "offline_access"],
64
+ authentication: { method: "oauth2", registrationRef: "databricks" },
65
+ settings: { workspaceUrl: "https://dbc-abc123.cloud.databricks.com" }, extensions: { keep: "workspace" }
66
+ } },
67
+ registrations: {
68
+ snowflake: { source: "own", clientId: "snowflake-client", clientSecretRef: "env:SNOWFLAKE_SECRET", callbackUrlRef: "env:SNOWFLAKE_CALLBACK" },
69
+ google: { source: "own", clientId: "original-client", clientSecretRef: "env:GOOGLE_SECRET", callbackUrlRef: "env:CALLBACK" },
70
+ slack: { source: "own", clientId: "slack-client", clientSecretRef: "env:SLACK_SECRET", callbackUrlRef: "env:SLACK_CALLBACK" },
71
+ databricks: { source: "own", clientId: "user-client", clientSecretRef: "env:DATABRICKS_SECRET", callbackUrlRef: "env:DATABRICKS_CALLBACK" }
72
+ }
73
+ });
74
+ const visible = ref(true);
75
+ const errors = ref({});
76
+ const exported = ref("");
77
+ const source = ref("");
78
+ const locked = ref(false);
79
+ const activeId = ref("calendar");
80
+ const providers = [bigqueryDefinition, gmailDefinition, googleDriveDefinition, googleAnalyticsDefinition, contentfulDefinition, snowflakeDefinition, googleCalendarDefinition, mailgunDefinition, algoliaDefinition, clickhouseDefinition, slackDefinition, amazonRedshiftDefinition, databricksDefinition];
81
+ const provider = computed(() => providers.find((entry) => entry.id === configuration.value.integrations[activeId.value].provider));
82
+ function save() {
83
+ try {
84
+ const validated = validateIntegrationConfiguration(configuration.value, { providers });
85
+ errors.value = {};
86
+ exported.value = JSON.stringify(validated, null, 2);
87
+ } catch (error) { errors.value = error.fieldErrors; }
88
+ }
89
+ function importSource() {
90
+ try {
91
+ configuration.value = parseIntegrationConfiguration(source.value, { providers });
92
+ errors.value = {};
93
+ } catch (error) { errors.value = error.fieldErrors; }
94
+ }
95
+ </script>
96
+
97
+ <template>
98
+ <v-app>
99
+ <v-main>
100
+ <v-container style="max-width: 900px">
101
+ <v-btn class="mb-4" min-height="48" @click="visible = !visible">{{ visible ? 'Leave configuration' : 'Return to configuration' }}</v-btn>
102
+ <v-switch v-model="locked" label="Lock form" />
103
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'content'">Edit Contentful</v-btn>
104
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'analytics'">Edit Analytics</v-btn>
105
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'drive'">Edit Drive</v-btn>
106
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'gmail'">Edit Gmail</v-btn>
107
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'bigquery'">Edit BigQuery</v-btn>
108
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'mail'">Edit Mailgun</v-btn>
109
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'search'">Edit Algolia</v-btn>
110
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'warehouse'">Edit ClickHouse</v-btn>
111
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'slack'">Edit Slack</v-btn>
112
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'redshift'">Edit Redshift</v-btn>
113
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'databricks'">Edit Databricks</v-btn>
114
+ <v-btn class="mb-4" min-height="48" @click="activeId = 'snowflake'">Edit Snowflake</v-btn>
115
+ <IntegrationConfigurationFields
116
+ v-if="visible" v-model="configuration" :integration-id="activeId"
117
+ :provider="provider" :field-errors="errors" :disabled="locked"
118
+ />
119
+ <v-btn class="my-4" min-height="48" color="primary" @click="save">Export configuration</v-btn>
120
+ <v-textarea v-model="source" label="Import configuration JSON" />
121
+ <v-btn min-height="48" @click="importSource">Import configuration</v-btn>
122
+ <pre data-testid="export" class="mt-4" style="white-space: pre-wrap; overflow-wrap: anywhere">{{ exported }}</pre>
123
+ </v-container>
124
+ </v-main>
125
+ </v-app>
126
+ </template>
@@ -0,0 +1,2 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Integration configuration test</title></head><body><div id="app"></div><script type="module" src="/main.js"></script></body></html>
@@ -0,0 +1,8 @@
1
+ import { createApp } from "vue";
2
+ import { createVuetify } from "vuetify";
3
+ import * as components from "vuetify/components";
4
+ import * as directives from "vuetify/directives";
5
+ import "vuetify/styles";
6
+ import App from "./App.vue";
7
+
8
+ createApp(App).use(createVuetify({ components, directives })).mount("#app");
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "@playwright/test";
2
+
3
+ const managedUrl = process.env.PLAYWRIGHT_BASE_URL;
4
+ export default defineConfig({
5
+ testDir: ".",
6
+ testMatch: "configuration.spec.js",
7
+ workers: 1,
8
+ use: {
9
+ baseURL: managedUrl || "http://127.0.0.1:4187",
10
+ storageState: process.env.VIBE64_PLAYWRIGHT_STORAGE_STATE || undefined
11
+ },
12
+ webServer: managedUrl ? undefined : {
13
+ command: "npm exec --no -- vite --config packages/connectors-web/test/vite.config.js",
14
+ cwd: new URL("../../../", import.meta.url).pathname,
15
+ url: "http://127.0.0.1:4187",
16
+ reuseExistingServer: false
17
+ }
18
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vite";
2
+ import vue from "@vitejs/plugin-vue";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export default defineConfig({
6
+ root: fileURLToPath(new URL("./fixture", import.meta.url)),
7
+ plugins: [vue()],
8
+ server: { host: "127.0.0.1", port: 4187, strictPort: true }
9
+ });
@@ -0,0 +1,4 @@
1
+ {
2
+ "status": "passed",
3
+ "failedTests": []
4
+ }