Compare commits
188 Commits
fix/dev-en
...
persistent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ad741c0d2 | ||
|
|
69570bc65f | ||
|
|
b719615aa6 | ||
|
|
afcc49f6f6 | ||
|
|
d12184f4a0 | ||
|
|
bde8a43aeb | ||
|
|
76f8cb6ce3 | ||
|
|
31840a1a31 | ||
|
|
a5968cdf24 | ||
|
|
5bcdea9b51 | ||
|
|
8eafca6c74 | ||
|
|
6adf7f2ef8 | ||
|
|
2ea7960d5c | ||
|
|
66af12b9cd | ||
|
|
8171f24196 | ||
|
|
a86dffa344 | ||
|
|
db09e70823 | ||
|
|
407f3bc2df | ||
|
|
e9b56f49b5 | ||
|
|
307b2906b7 | ||
|
|
5b1181eba5 | ||
|
|
1574a28b4f | ||
|
|
b8d3ac0869 | ||
|
|
ad4dfb41b1 | ||
|
|
ace1e034a2 | ||
|
|
9142569d91 | ||
|
|
38e28e0ec3 | ||
|
|
30c6275dc5 | ||
|
|
0f2e7ea906 | ||
|
|
cff57cbbb5 | ||
|
|
655a3f6ac7 | ||
|
|
62c13bb028 | ||
|
|
3c968befff | ||
|
|
8f7064e87f | ||
|
|
334ed1fe42 | ||
|
|
8e79105b9b | ||
|
|
92cd3f6cbf | ||
|
|
7089932b89 | ||
|
|
27e5f298d8 | ||
|
|
c4a27f18d3 | ||
|
|
811e4466b2 | ||
|
|
48c41ec6c8 | ||
|
|
b93319ab57 | ||
|
|
31905eaba4 | ||
|
|
a0a14a42d3 | ||
|
|
839784b50c | ||
|
|
e9bc4ac447 | ||
|
|
7180c6ae4b | ||
|
|
a57a1c0ab8 | ||
|
|
bee30e6423 | ||
|
|
281aa0dc12 | ||
|
|
14bd0853f6 | ||
|
|
8be374db14 | ||
|
|
c6dff735a9 | ||
|
|
b7e1d1eff8 | ||
|
|
3dd4c46871 | ||
|
|
e7ae8072ca | ||
|
|
483f2086dd | ||
|
|
cb9c042404 | ||
|
|
1b26236cf1 | ||
|
|
14ff22021e | ||
|
|
55df67b632 | ||
|
|
53b678bcd6 | ||
|
|
daaca9ef62 | ||
|
|
6a15fee1c5 | ||
|
|
92c07ddea1 | ||
|
|
3dd99b6494 | ||
|
|
edb5eaad63 | ||
|
|
ad9b980fef | ||
|
|
96a308a64e | ||
|
|
77afdaafc0 | ||
|
|
56f04b92ca | ||
|
|
8020b65546 | ||
|
|
0cc35ac876 | ||
|
|
617ca0f5f0 | ||
|
|
607fb9ba58 | ||
|
|
3c24edbf47 | ||
|
|
74c8811981 | ||
|
|
ce15b2d9f0 | ||
|
|
e5db491df4 | ||
|
|
fdffe7dc82 | ||
|
|
3bd3bf5b6a | ||
|
|
c940bb3935 | ||
|
|
366c74c02e | ||
|
|
0303419907 | ||
|
|
1511899a7e | ||
|
|
b1a2673e1a | ||
|
|
7074e0236a | ||
|
|
ddba65c368 | ||
|
|
93b375bbe2 | ||
|
|
d8bb291939 | ||
|
|
ca3a2ea136 | ||
|
|
1132bfd402 | ||
|
|
e9f6c93013 | ||
|
|
37f3a01838 | ||
|
|
87dc660191 | ||
|
|
40691f70d8 | ||
|
|
f4b0e5946b | ||
|
|
f3a2cfab7f | ||
|
|
817eb1d787 | ||
|
|
89786605c1 | ||
|
|
8b5224136b | ||
|
|
051c1a6b25 | ||
|
|
c3c7feeddf | ||
|
|
274b7de269 | ||
|
|
a0bea40dab | ||
|
|
2576505465 | ||
|
|
9f5298fa9a | ||
|
|
07f25c0cc7 | ||
|
|
cfb3ab406c | ||
|
|
82f5dea54d | ||
|
|
ad62aed71a | ||
|
|
3ab76adf8d | ||
|
|
1f019c1e10 | ||
|
|
3e4335a798 | ||
|
|
70967125aa | ||
|
|
5921542894 | ||
|
|
8c572f77f4 | ||
|
|
7e5bb21c3d | ||
|
|
2c8c0e30aa | ||
|
|
4ca03e17ef | ||
|
|
ec2b0f38ae | ||
|
|
2bad85564e | ||
|
|
d1252c7e8e | ||
|
|
4a7059cac7 | ||
|
|
9bd22bf4b1 | ||
|
|
0462cde56d | ||
|
|
e99312fe64 | ||
|
|
d8a7cd3501 | ||
|
|
2d2c4b35a5 | ||
|
|
9e717a6056 | ||
|
|
9ed603e6ce | ||
|
|
8a5545ad32 | ||
|
|
82b89e31fb | ||
|
|
607985e054 | ||
|
|
a2e43e1996 | ||
|
|
3b96cd4619 | ||
|
|
31790b6052 | ||
|
|
846f38d443 | ||
|
|
e7a9e8c100 | ||
|
|
e1b7b8e912 | ||
|
|
2480ce058b | ||
|
|
cf04ba48c2 | ||
|
|
8a853116bf | ||
|
|
c54c9ded12 | ||
|
|
0fc1a4b8c9 | ||
|
|
6ca5625902 | ||
|
|
a4fdcdef64 | ||
|
|
b1fe71ca2f | ||
|
|
4daf2cb8a2 | ||
|
|
4a3d739318 | ||
|
|
ba85c0513c | ||
|
|
b8c26e3c0c | ||
|
|
21b5b3e93a | ||
|
|
0a1a13b48b | ||
|
|
a18f7a94e2 | ||
|
|
067626c71d | ||
|
|
3bc10ee2e1 | ||
|
|
b9fba6ab7a | ||
|
|
20e5611810 | ||
|
|
37d68bca75 | ||
|
|
e58e0b7f80 | ||
|
|
81aab7b8ca | ||
|
|
bcf8a422f6 | ||
|
|
ac434a50aa | ||
|
|
4f050fba0a | ||
|
|
c36a72146e | ||
|
|
d82ec362ba | ||
|
|
c2172311f5 | ||
|
|
5a7bcfab93 | ||
|
|
decbc82b1a | ||
|
|
bd0f5b3b69 | ||
|
|
7f98271a59 | ||
|
|
8cc219994d | ||
|
|
84168dbf05 | ||
|
|
d6da219a70 | ||
|
|
65426fe653 | ||
|
|
6403d80fea | ||
|
|
bbcba1500c | ||
|
|
fcf4dbb6e8 | ||
|
|
3d75754ad8 | ||
|
|
af72309d8e | ||
|
|
646b4c4548 | ||
|
|
92ada9186c | ||
|
|
e67ce4582e | ||
|
|
25e3afcdbb | ||
|
|
b31a9a09c5 | ||
|
|
50614c15aa |
@@ -1,4 +1,4 @@
|
||||
--b-
|
||||
---
|
||||
name: add-tracing
|
||||
description: Add OpenTelemetry tracing spans to Clojure code following Metabase tracing conventions. Use when instrumenting backend code with trace coverage.
|
||||
---
|
||||
|
||||
@@ -596,6 +596,9 @@
|
||||
metabase.model-persistence.api-test/with-persistence-setup! hooks.common/with-one-top-level-binding
|
||||
metabase.model-persistence.api-test/with-setup! hooks.common/with-one-top-level-binding
|
||||
metabase.model-persistence.test-util/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
|
||||
metabase.mq.core/with-queue hooks.common/let-second
|
||||
metabase.mq.core/with-topic hooks.common/let-second
|
||||
metabase.mq.core/def-listener! hooks.metabase.mq.impl/def-listener
|
||||
metabase.models.interface/define-batched-hydration-method hooks.metabase.models.interface/define-hydration-method
|
||||
metabase.models.interface/define-simple-hydration-method hooks.metabase.models.interface/define-hydration-method
|
||||
metabase.permissions.core/allow-changing-all-users-group-members hooks.common/do*
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
;; ## `model-exports`:
|
||||
;;
|
||||
;; Set of `:model/X` keywords defined by this module that it allows others to reference. By default all are private.
|
||||
;; If you know what you're doing, we support an :all keyword, but let's prefer explicit sets for now.
|
||||
;; If you know what you're doing, we support an :any keyword, but let's prefer explicit sets for now.
|
||||
;;
|
||||
;; enterprise/workspaces
|
||||
;; {...
|
||||
@@ -62,12 +62,25 @@
|
||||
;; ## `model-imports`:
|
||||
;;
|
||||
;; Set of `:model/X` keywords that this module is allowed to reference from other modules. Defaults to none.
|
||||
;; If you know what you're doing, we support an :all keyword, but let's prefer explicit sets for now.
|
||||
;; If you know what you're doing, we support an :any keyword, but let's prefer explicit sets for now.
|
||||
;;
|
||||
;; transforms-base
|
||||
;; {...
|
||||
;; :model-imports #{:model/Database :model/Field :model/Table :model/Transform}}
|
||||
;;
|
||||
;; ### `:bypass`
|
||||
;;
|
||||
;; Some modules (like `cmd` and `enterprise/serialization`) are inherently cross-cutting and import nearly
|
||||
;; every model. For these, use `:model-imports :bypass` instead of an explicit set:
|
||||
;;
|
||||
;; cmd
|
||||
;; {...
|
||||
;; :model-imports :bypass}
|
||||
;;
|
||||
;; A bypassed module can reference any model — even ones that are not exported. Models that are *only*
|
||||
;; referenced outside their home module by bypassed modules do not need to appear in `:model-exports`
|
||||
;; (in fact, they must not — the staleness test will flag them).
|
||||
;;
|
||||
;; ## Tips
|
||||
;;
|
||||
;; PRO TIP: Check out the [[dev.deps-graph]] namespace for helpful tools to see where a module is used externally, and
|
||||
@@ -97,7 +110,7 @@
|
||||
settings
|
||||
util
|
||||
warehouses}
|
||||
:model-exports #{:model/Action :model/HTTPAction :model/ImplicitAction :model/QueryAction}
|
||||
:model-exports #{:model/Action}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
@@ -133,7 +146,6 @@
|
||||
events
|
||||
models
|
||||
util}
|
||||
:model-exports #{:model/RecentViews}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
@@ -290,7 +302,11 @@
|
||||
connection-pool
|
||||
task
|
||||
util}
|
||||
:model-imports #{:model/Database :model/Field :model/QueryCache :model/Secret :model/Setting}}
|
||||
:model-imports #{:model/Database
|
||||
:model/Field
|
||||
:model/QueryCache
|
||||
:model/Secret
|
||||
:model/Setting}}
|
||||
|
||||
appearance
|
||||
{:team "UX West"
|
||||
@@ -372,12 +388,11 @@
|
||||
collections
|
||||
queries
|
||||
util}
|
||||
:model-exports #{:model/BookmarkOrdering
|
||||
:model/CardBookmark
|
||||
:model/CollectionBookmark
|
||||
:model/DashboardBookmark
|
||||
:model/DocumentBookmark}
|
||||
:model-imports #{:model/Card :model/Collection :model/Dashboard :model/Document}}
|
||||
:model-exports #{:model/CardBookmark :model/CollectionBookmark :model/DashboardBookmark}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
:model/Document}}
|
||||
|
||||
bug-reporting
|
||||
{:team "UX West"
|
||||
@@ -410,7 +425,10 @@
|
||||
settings
|
||||
util}
|
||||
:model-exports #{:model/CacheConfig :model/QueryCache}
|
||||
:model-imports #{:model/Card :model/Dashboard :model/DashboardCard :model/Database}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Dashboard
|
||||
:model/DashboardCard
|
||||
:model/Database}}
|
||||
|
||||
;; TODO -- way too many API namespaces.
|
||||
channel
|
||||
@@ -452,7 +470,10 @@
|
||||
types
|
||||
util}
|
||||
:model-exports #{:model/Channel :model/ChannelTemplate}
|
||||
:model-imports #{:model/Dashboard :model/Database :model/PulseChannel :model/User}}
|
||||
:model-imports #{:model/Dashboard
|
||||
:model/Database
|
||||
:model/PulseChannel
|
||||
:model/User}}
|
||||
|
||||
classloader
|
||||
{:team "DevEx"
|
||||
@@ -500,82 +521,7 @@
|
||||
search
|
||||
settings
|
||||
util}
|
||||
:model-imports #{:model/Action
|
||||
:model/ApiKey
|
||||
:model/ApplicationPermissionsRevision
|
||||
:model/AuditLog
|
||||
:model/AuthIdentity
|
||||
:model/BookmarkOrdering
|
||||
:model/Card
|
||||
:model/CardBookmark
|
||||
:model/Channel
|
||||
:model/ChannelTemplate
|
||||
:model/Collection
|
||||
:model/CollectionBookmark
|
||||
:model/CollectionPermissionGraphRevision
|
||||
:model/Comment
|
||||
:model/CommentReaction
|
||||
:model/ConnectionImpersonation
|
||||
:model/Dashboard
|
||||
:model/DashboardBookmark
|
||||
:model/DashboardCard
|
||||
:model/DashboardCardSeries
|
||||
:model/DashboardTab
|
||||
:model/DataPermissions
|
||||
:model/Database
|
||||
:model/Dimension
|
||||
:model/Document
|
||||
:model/DocumentBookmark
|
||||
:model/Field
|
||||
:model/FieldUserSettings
|
||||
:model/FieldValues
|
||||
:model/Glossary
|
||||
:model/HTTPAction
|
||||
:model/ImplicitAction
|
||||
:model/LoginHistory
|
||||
:model/Measure
|
||||
:model/Metabot
|
||||
:model/MetabotConversation
|
||||
:model/MetabotMessage
|
||||
:model/MetabotPrompt
|
||||
:model/ModelIndex
|
||||
:model/ModelIndexValue
|
||||
:model/ModerationReview
|
||||
:model/NativeQuerySnippet
|
||||
:model/Notification
|
||||
:model/NotificationCard
|
||||
:model/NotificationHandler
|
||||
:model/NotificationRecipient
|
||||
:model/NotificationSubscription
|
||||
:model/OAuthAccessToken
|
||||
:model/OAuthAuthorizationCode
|
||||
:model/OAuthClient
|
||||
:model/OAuthRefreshToken
|
||||
:model/ParameterCard
|
||||
:model/Permissions
|
||||
:model/PermissionsGroup
|
||||
:model/PermissionsGroupMembership
|
||||
:model/PermissionsRevision
|
||||
:model/PersistedInfo
|
||||
:model/Pulse
|
||||
:model/PulseCard
|
||||
:model/PulseChannel
|
||||
:model/PulseChannelRecipient
|
||||
:model/QueryAction
|
||||
:model/RecentViews
|
||||
:model/Revision
|
||||
:model/Sandbox
|
||||
:model/Secret
|
||||
:model/Segment
|
||||
:model/Session
|
||||
:model/Setting
|
||||
:model/Table
|
||||
:model/Tenant
|
||||
:model/Timeline
|
||||
:model/TimelineEvent
|
||||
:model/User
|
||||
:model/UserParameterValue
|
||||
:model/ViewLog}}
|
||||
:model-imports :bypass}
|
||||
|
||||
collections
|
||||
{:team "UX West"
|
||||
@@ -656,7 +602,7 @@
|
||||
request
|
||||
users
|
||||
util}
|
||||
:model-exports #{:model/Comment :model/CommentReaction}
|
||||
:model-exports #{:model/Comment}
|
||||
:model-imports #{:model/Document :model/User}}
|
||||
|
||||
;; technically this 'uses' `enterprise/core` and `test` since it tries to load them to see if they exists so we know
|
||||
@@ -787,7 +733,11 @@
|
||||
sync
|
||||
util
|
||||
warehouse-schema}
|
||||
:model-imports #{:model/Database :model/Dimension :model/Field :model/FieldValues :model/Table}}
|
||||
:model-imports #{:model/Database
|
||||
:model/Dimension
|
||||
:model/Field
|
||||
:model/FieldValues
|
||||
:model/Table}}
|
||||
|
||||
database-routing
|
||||
{:team "UX West"
|
||||
@@ -817,7 +767,11 @@
|
||||
util
|
||||
view-log}
|
||||
:model-exports #{:model/Document}
|
||||
:model-imports #{:model/Card :model/Collection :model/Dashboard :model/Table :model/User}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
driver
|
||||
{:team "Querying Platform"
|
||||
@@ -860,6 +814,7 @@
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
mq
|
||||
premium-features
|
||||
query-processor
|
||||
settings
|
||||
@@ -1049,7 +1004,11 @@
|
||||
metabase.internal-stats.metabot}
|
||||
:uses #{app-db
|
||||
models}
|
||||
:model-imports #{:model/Card :model/Dashboard :model/MetabotMessage :model/QueryExecution :model/User}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Dashboard
|
||||
:model/MetabotMessage
|
||||
:model/QueryExecution
|
||||
:model/User}}
|
||||
|
||||
legacy-mbql
|
||||
{:team "Querying Platform"
|
||||
@@ -1137,7 +1096,8 @@
|
||||
|
||||
llm
|
||||
{:team "Metabot"
|
||||
:api #{metabase.llm.api
|
||||
:api #{metabase.llm.anthropic
|
||||
metabase.llm.api
|
||||
metabase.llm.init
|
||||
metabase.llm.settings}
|
||||
:uses #{analytics
|
||||
@@ -1252,10 +1212,7 @@
|
||||
transforms-base
|
||||
util
|
||||
warehouses}
|
||||
:model-exports #{:model/Metabot
|
||||
:model/MetabotConversation
|
||||
:model/MetabotMessage
|
||||
:model/MetabotPrompt}
|
||||
:model-exports #{:model/MetabotMessage}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
@@ -1335,7 +1292,8 @@
|
||||
settings
|
||||
transforms
|
||||
transforms-base
|
||||
util}
|
||||
util
|
||||
warehouse-schema}
|
||||
:friends #{transforms}
|
||||
:model-exports #{:model/Transform
|
||||
:model/TransformJob
|
||||
@@ -1354,6 +1312,17 @@
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
mq
|
||||
{:team "UX West"
|
||||
:api #{metabase.mq.core
|
||||
metabase.mq.init}
|
||||
:uses #{analytics
|
||||
app-db
|
||||
models
|
||||
settings
|
||||
startup
|
||||
util}}
|
||||
|
||||
native-query-snippets
|
||||
{:team "Graphy"
|
||||
:api #{metabase.native-query-snippets.api
|
||||
@@ -1402,11 +1371,7 @@
|
||||
tracing
|
||||
users
|
||||
util}
|
||||
:model-exports #{:model/Notification
|
||||
:model/NotificationCard
|
||||
:model/NotificationHandler
|
||||
:model/NotificationRecipient
|
||||
:model/NotificationSubscription}
|
||||
:model-exports #{:model/Notification :model/NotificationHandler :model/NotificationRecipient}
|
||||
:model-imports #{:model/Card
|
||||
:model/Channel
|
||||
:model/ChannelTemplate
|
||||
@@ -1425,11 +1390,7 @@
|
||||
models
|
||||
settings
|
||||
system
|
||||
util}
|
||||
:model-exports #{:model/OAuthAccessToken
|
||||
:model/OAuthAuthorizationCode
|
||||
:model/OAuthClient
|
||||
:model/OAuthRefreshToken}}
|
||||
util}}
|
||||
|
||||
;; TODO -- too many API namespaces
|
||||
parameters
|
||||
@@ -1454,7 +1415,11 @@
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses}
|
||||
:model-imports #{:model/Card :model/Dashboard :model/Field :model/FieldValues :model/Table}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Dashboard
|
||||
:model/Field
|
||||
:model/FieldValues
|
||||
:model/Table}}
|
||||
|
||||
permissions
|
||||
{:team "UX West"
|
||||
@@ -1482,7 +1447,10 @@
|
||||
:model/PermissionsGroup
|
||||
:model/PermissionsGroupMembership
|
||||
:model/PermissionsRevision}
|
||||
:model-imports #{:model/Collection :model/Database :model/Table :model/User}}
|
||||
:model-imports #{:model/Collection
|
||||
:model/Database
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
permissions-rest
|
||||
{:team "UX West"
|
||||
@@ -1550,7 +1518,10 @@
|
||||
settings
|
||||
task
|
||||
util}
|
||||
:model-imports #{:model/Card :model/Dashboard :model/Database :model/User}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Dashboard
|
||||
:model/Database
|
||||
:model/User}}
|
||||
|
||||
public-sharing
|
||||
{:team "UX West"
|
||||
@@ -1608,7 +1579,10 @@
|
||||
util
|
||||
enterprise/advanced-permissions
|
||||
enterprise/sandbox}
|
||||
:model-exports #{:model/Pulse :model/PulseCard :model/PulseChannel :model/PulseChannelRecipient}
|
||||
:model-exports #{:model/Pulse
|
||||
:model/PulseCard
|
||||
:model/PulseChannel
|
||||
:model/PulseChannelRecipient}
|
||||
:model-imports #{:model/Card
|
||||
:model/Channel
|
||||
:model/Collection
|
||||
@@ -1653,7 +1627,6 @@
|
||||
view-log
|
||||
warehouse-schema}
|
||||
:model-exports #{:model/Card
|
||||
:model/ParameterCard
|
||||
:model/Query
|
||||
:model/QueryExecution
|
||||
:model/QueryTable}
|
||||
@@ -1856,7 +1829,8 @@
|
||||
metabase.search.ingestion
|
||||
metabase.search.init
|
||||
metabase.search.scoring
|
||||
metabase.search.spec}
|
||||
metabase.search.spec
|
||||
metabase.search.task.search-index}
|
||||
:uses #{analytics
|
||||
api
|
||||
app-db
|
||||
@@ -1868,6 +1842,7 @@
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
mq
|
||||
permissions
|
||||
premium-features
|
||||
queries
|
||||
@@ -1879,7 +1854,11 @@
|
||||
tracing
|
||||
transforms
|
||||
util}
|
||||
:model-imports #{:model/Card :model/Collection :model/Dashboard :model/Table :model/User}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
secrets
|
||||
{:team "Graphy"
|
||||
@@ -1965,6 +1944,7 @@
|
||||
config
|
||||
events
|
||||
models
|
||||
mq
|
||||
premium-features
|
||||
util
|
||||
enterprise/advanced-permissions}
|
||||
@@ -2117,7 +2097,10 @@
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses}
|
||||
:model-imports #{:model/Database :model/Field :model/FieldValues :model/Table}}
|
||||
:model-imports #{:model/Database
|
||||
:model/Field
|
||||
:model/FieldValues
|
||||
:model/Table}}
|
||||
|
||||
system
|
||||
{:team "UX West"
|
||||
@@ -2197,7 +2180,7 @@
|
||||
events
|
||||
models
|
||||
util}
|
||||
:model-exports #{:model/Timeline :model/TimelineEvent}
|
||||
:model-exports #{:model/Timeline}
|
||||
:model-imports #{:model/Collection :model/User}}
|
||||
|
||||
tracing
|
||||
@@ -2320,7 +2303,10 @@
|
||||
sync
|
||||
util
|
||||
warehouse-schema}
|
||||
:model-imports #{:model/Card :model/Database :model/Field :model/Table}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Database
|
||||
:model/Field
|
||||
:model/Table}}
|
||||
|
||||
user-key-value
|
||||
{:team "UX West"
|
||||
@@ -2449,11 +2435,9 @@
|
||||
remote-sync
|
||||
search
|
||||
util
|
||||
warehouses
|
||||
workspaces}
|
||||
warehouses}
|
||||
:model-exports #{:model/Dimension
|
||||
:model/Field
|
||||
:model/FieldUserSettings
|
||||
:model/FieldValues
|
||||
:model/Table}
|
||||
:model-imports #{:model/Card
|
||||
@@ -2486,7 +2470,11 @@
|
||||
util
|
||||
warehouse-schema
|
||||
xrays}
|
||||
:model-imports #{:model/Database :model/Dimension :model/Field :model/FieldValues :model/Table}}
|
||||
:model-imports #{:model/Database
|
||||
:model/Dimension
|
||||
:model/Field
|
||||
:model/FieldValues
|
||||
:model/Table}}
|
||||
|
||||
warehouses
|
||||
{:team "UX West"
|
||||
@@ -2508,7 +2496,10 @@
|
||||
sync
|
||||
util}
|
||||
:model-exports #{:model/Database}
|
||||
:model-imports #{:model/Card :model/Field :model/Table :model/User}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Field
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
warehouses-rest
|
||||
{:team "UX West"
|
||||
@@ -2538,11 +2529,14 @@
|
||||
warehouse-schema
|
||||
warehouses
|
||||
enterprise/advanced-permissions}
|
||||
:model-imports #{:model/Card :model/Collection :model/Database :model/Field :model/Table}}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Database
|
||||
:model/Field
|
||||
:model/Table}}
|
||||
|
||||
workspaces
|
||||
{:team "Metabot"
|
||||
:api #{metabase.workspaces.core}
|
||||
:uses #{util}}
|
||||
|
||||
xrays
|
||||
@@ -2590,10 +2584,14 @@
|
||||
events
|
||||
lib
|
||||
models
|
||||
mq
|
||||
query-processor
|
||||
util
|
||||
warehouse-schema}
|
||||
:model-imports #{:model/Database :model/Field :model/FieldValues :model/Table}}
|
||||
:model-imports #{:model/Database
|
||||
:model/Field
|
||||
:model/FieldValues
|
||||
:model/Table}}
|
||||
|
||||
enterprise/advanced-config
|
||||
{:team "UX West"
|
||||
@@ -2650,7 +2648,6 @@
|
||||
:api #{}
|
||||
:uses #{driver
|
||||
premium-features
|
||||
sso
|
||||
enterprise/advanced-config
|
||||
enterprise/scim
|
||||
enterprise/semantic-search
|
||||
@@ -2801,7 +2798,10 @@
|
||||
permissions
|
||||
premium-features
|
||||
util}
|
||||
:model-imports #{:model/Collection :model/Dimension :model/Field :model/Table}}
|
||||
:model-imports #{:model/Collection
|
||||
:model/Dimension
|
||||
:model/Field
|
||||
:model/Table}}
|
||||
|
||||
enterprise/database-replication
|
||||
{:team "Cloud"
|
||||
@@ -3156,7 +3156,7 @@
|
||||
server
|
||||
setup
|
||||
util}
|
||||
:model-imports #{:model/Card :model/Collection :model/Dashboard :model/User}}
|
||||
:model-imports :bypass}
|
||||
|
||||
enterprise/snippet-collections
|
||||
{:team "Graphy"
|
||||
@@ -3254,7 +3254,10 @@
|
||||
transforms
|
||||
transforms-base
|
||||
util}
|
||||
:model-imports #{:model/Database :model/Field :model/Table :model/Transform}}
|
||||
:model-imports #{:model/Database
|
||||
:model/Field
|
||||
:model/Table
|
||||
:model/Transform}}
|
||||
|
||||
enterprise/transforms-python
|
||||
{:team "Graphy"
|
||||
@@ -3278,7 +3281,10 @@
|
||||
transforms-base
|
||||
util}
|
||||
:model-exports #{:model/PythonLibrary}
|
||||
:model-imports #{:model/Database :model/Field :model/Table :model/TransformRun}}
|
||||
:model-imports #{:model/Database
|
||||
:model/Field
|
||||
:model/Table
|
||||
:model/TransformRun}}
|
||||
|
||||
enterprise/upload-management
|
||||
{:team "UX West"
|
||||
@@ -3343,6 +3349,7 @@
|
||||
"test[-.]dataset" ; anything that contains `test.dataset` or `test-dataset`
|
||||
"test\\.impl" ; anything that contains `test.impl`
|
||||
"test-setup" ; anything that contains `test-setup`
|
||||
"test[-_]helpers" ; anything that contains `test-helpers` or `test_helpers`
|
||||
"^metabase(?:-enterprise)?\\.test"}}}} ; anything starting with `metabase.test` or `metabase-enterprise.test`
|
||||
|
||||
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
24
.clj-kondo/src/hooks/metabase/mq/impl.clj
Normal file
24
.clj-kondo/src/hooks/metabase/mq/impl.clj
Normal file
@@ -0,0 +1,24 @@
|
||||
(ns hooks.metabase.mq.impl
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn def-listener
|
||||
"Kondo hook for `mq/def-listener`. Rewrites to a `fn` form so kondo can analyze bindings.
|
||||
|
||||
Forms:
|
||||
(mq/def-listener! :topic/foo [msg] body...)
|
||||
(mq/def-listener! :queue/bar {:config map} [msgs] body...)"
|
||||
[{:keys [node]}]
|
||||
(let [[_ _channel-name & args] (:children node)
|
||||
;; skip optional config map
|
||||
args (if (hooks/map-node? (first args))
|
||||
(rest args)
|
||||
args)
|
||||
[bindings & body] args
|
||||
;; rewrite as: (fn [bindings...] body...)
|
||||
new-node (hooks/list-node
|
||||
(list*
|
||||
(hooks/token-node 'fn)
|
||||
bindings
|
||||
body))]
|
||||
{:node new-node}))
|
||||
@@ -303,7 +303,6 @@
|
||||
metabase.util.malli/with [[:default]]
|
||||
metabase.util.malli/with-api-error-message [[:default]]
|
||||
metabase.util.namespaces/import-fns [[:default]]
|
||||
metabase.util.queue-test/await-test-while [[:block 1]]
|
||||
metabase.util/auto-retry [[:block 1]]
|
||||
metabase.util/case-enum [[:block 1]]
|
||||
metabase.util/for-map [[:block 1]]
|
||||
|
||||
119
.github/workflows/cross-version.yml
vendored
119
.github/workflows/cross-version.yml
vendored
@@ -61,13 +61,38 @@ jobs:
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
|
||||
- name: Prepare Cypress environment
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
- name: Run cross-version test
|
||||
env:
|
||||
CURRENT_VERSION: ${{ vars.CURRENT_VERSION }}
|
||||
RESULT_FILE: ${{ runner.temp }}/cross-version-result-${{ matrix.source }}-${{ matrix.target }}.json
|
||||
run: |
|
||||
cd cross-version
|
||||
./test.sh --source "${{ matrix.source }}" --target "${{ matrix.target }}"
|
||||
|
||||
- name: Upload failure result
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: failure-result-${{ matrix.source }}-${{ matrix.target }}
|
||||
path: ${{ runner.temp }}/cross-version-result-${{ matrix.source }}-${{ matrix.target }}.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload Cypress Artifacts upon failure
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: cypress-recording-${{ matrix.source }}-${{ matrix.target }}
|
||||
path: |
|
||||
./cypress
|
||||
./logs/test.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Show logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
@@ -75,6 +100,84 @@ jobs:
|
||||
docker compose logs metabase || true
|
||||
docker compose logs postgres || true
|
||||
|
||||
# Notify Slack on nightly failures
|
||||
notify-on-failure:
|
||||
if: always() && github.event_name == 'schedule' && needs.test-matrix.result == 'failure'
|
||||
needs: [test-matrix]
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
release
|
||||
.github
|
||||
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
|
||||
- name: Download failure results
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: failure-result-*
|
||||
path: failure-results
|
||||
merge-multiple: true
|
||||
|
||||
- name: Notify Slack
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { buildFailureResults, sendCrossVersionSlackNotification } = require('${{ github.workspace }}/release/dist/index.cjs');
|
||||
|
||||
// Get all failed jobs from the GitHub API
|
||||
const { owner, repo } = context.repo;
|
||||
const jobInfo = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: context.runId,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const failedJobs = jobInfo.data.jobs
|
||||
.filter(job => job.status === 'completed' && job.conclusion === 'failure' && job.name.startsWith('test-matrix'))
|
||||
.map(job => ({ name: job.name, url: job.html_url }));
|
||||
|
||||
if (failedJobs.length === 0) {
|
||||
console.log('No failed test-matrix jobs found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load artifact data for enrichment (phase)
|
||||
const resultsDir = 'failure-results';
|
||||
const artifactData = new Map();
|
||||
if (fs.existsSync(resultsDir)) {
|
||||
for (const file of fs.readdirSync(resultsDir)) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
try {
|
||||
const result = JSON.parse(fs.readFileSync(path.join(resultsDir, file), 'utf8'));
|
||||
artifactData.set(`${result.source}-${result.target}`, result);
|
||||
} catch (e) {
|
||||
console.log(`Skipping malformed result: ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const failures = buildFailureResults(failedJobs, artifactData);
|
||||
const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${context.runId}`;
|
||||
const slackBotToken = process.env.SLACK_BOT_TOKEN;
|
||||
if (!slackBotToken) {
|
||||
console.log('SLACK_BOT_TOKEN is not set; skipping Slack notification.');
|
||||
return;
|
||||
}
|
||||
await sendCrossVersionSlackNotification(failures, runUrl, slackBotToken);
|
||||
console.log('Slack notification sent');
|
||||
|
||||
# Manual test for workflow_dispatch
|
||||
test-manual:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
@@ -87,6 +190,12 @@ jobs:
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
|
||||
- name: Prepare Cypress environment
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
- name: Run cross-version test
|
||||
env:
|
||||
CURRENT_VERSION: ${{ vars.CURRENT_VERSION }}
|
||||
@@ -94,6 +203,16 @@ jobs:
|
||||
cd cross-version
|
||||
./test.sh --source "${{ inputs.source_version }}" --target "${{ inputs.target_version }}"
|
||||
|
||||
- name: Upload Cypress Artifacts upon failure
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: cypress-recording-${{ inputs.source_version }}-${{ inputs.target_version }}
|
||||
path: |
|
||||
./cypress
|
||||
./logs/test.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Show logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
|
||||
@@ -31,6 +31,11 @@ on:
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
fail_fast:
|
||||
description: "Stop running remaining tests after the first failure"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
determine-test-type:
|
||||
@@ -67,6 +72,7 @@ jobs:
|
||||
echo '- `grep`: "${{ inputs.grep }}"' >> $GITHUB_STEP_SUMMARY
|
||||
echo '- `qa_db`: "${{ inputs.qa_db }}"' >> $GITHUB_STEP_SUMMARY
|
||||
echo '- `enable_network_throttling`: "${{ inputs.enable_network_throttling }}"' >> $GITHUB_STEP_SUMMARY
|
||||
echo '- `fail_fast`: "${{ inputs.fail_fast }}"' >> $GITHUB_STEP_SUMMARY
|
||||
echo '' >> $GITHUB_STEP_SUMMARY
|
||||
echo 'triggered by: @${{ github.event.sender.login }}' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -144,6 +150,7 @@ jobs:
|
||||
env:
|
||||
GREP: ${{ github.event.inputs.grep }}
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
FAIL_FAST: ${{ inputs.fail_fast }}
|
||||
run: |
|
||||
node e2e/runner/run_cypress_ci.js ${{ needs.determine-test-type.outputs.type }} \
|
||||
--spec '${{ github.event.inputs.spec }}' \
|
||||
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
NAMED_MODULES=$(node --input-type=module -e "
|
||||
import { elements } from './frontend/lint/module-boundaries.mjs';
|
||||
const named = elements.filter(e => !['shared/other', 'app/misc', 'other'].includes(e.type));
|
||||
console.log(named.length);
|
||||
")
|
||||
|
||||
NAMED_DIRS=$(node --input-type=module -e "
|
||||
@@ -29,6 +30,7 @@ jobs:
|
||||
const m = el.pattern.match(/^frontend\/src\/metabase\/(\w[\w-]*)\//)
|
||||
if (m) dirs.add(m[1]);
|
||||
}
|
||||
for (const d of dirs) console.log(d);
|
||||
")
|
||||
TOTAL_DIRS=$(ls -d frontend/src/metabase/*/ | xargs -I{} basename {} | wc -l | tr -d ' ')
|
||||
NAMED_COUNT=$(echo "$NAMED_DIRS" | grep -c . || true)
|
||||
|
||||
53
.github/workflows/querying-team-pagerduty.yml
vendored
53
.github/workflows/querying-team-pagerduty.yml
vendored
@@ -13,32 +13,35 @@ jobs:
|
||||
(github.event.label.name == 'Priority:P0' || github.event.label.name == 'Priority:P1' || github.event.label.name == '.Escalation') &&
|
||||
contains(github.event.issue.labels.*.name, '.Team/Querying')
|
||||
steps:
|
||||
- name: Setting title
|
||||
uses: actions/github-script@v7
|
||||
id: vars
|
||||
with:
|
||||
script: |
|
||||
core.setOutput('issue_title', ${{ toJson(github.event.issue.title) }}.replaceAll(/"/g, '\\"'));
|
||||
- name: Trigger PagerDuty Incident
|
||||
env:
|
||||
ROUTING_KEY: ${{ secrets.QUERYING_PAGERDUTY_INTEGRATION_KEY }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
ALL_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
run: |
|
||||
payload=$(jq -n \
|
||||
--arg rk "$ROUTING_KEY" \
|
||||
--arg dk "github-issue-$ISSUE_NUMBER" \
|
||||
--arg sum "[$LABEL_NAME] $ISSUE_TITLE" \
|
||||
--arg src "GitHub Issues - $REPO" \
|
||||
--arg lbl "$ALL_LABELS" \
|
||||
--arg url "$ISSUE_URL" \
|
||||
'{
|
||||
routing_key: $rk,
|
||||
event_action: "trigger",
|
||||
dedup_key: $dk,
|
||||
payload: {
|
||||
summary: $sum,
|
||||
source: $src,
|
||||
severity: "critical",
|
||||
custom_details: { Labels: $lbl }
|
||||
},
|
||||
links: [{ href: $url, text: "View GitHub Issue" }]
|
||||
}')
|
||||
curl -X POST https://events.pagerduty.com/v2/enqueue \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"routing_key": "${{ secrets.QUERYING_PAGERDUTY_INTEGRATION_KEY }}",
|
||||
"event_action": "trigger",
|
||||
"dedup_key": "github-issue-${{ github.event.issue.number }}",
|
||||
"payload": {
|
||||
"summary": "[${{ github.event.label.name }}] ${{ steps.vars.outputs.issue_title }}",
|
||||
"source": "GitHub Issues - ${{ github.repository }}",
|
||||
"severity": "critical",
|
||||
"custom_details": {
|
||||
"Labels": "${{ join(github.event.issue.labels.*.name, ', ') }}"
|
||||
}
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"href": "${{ github.event.issue.html_url }}",
|
||||
"text": "View GitHub Issue"
|
||||
}
|
||||
]
|
||||
}'
|
||||
-d "$payload"
|
||||
|
||||
@@ -6,7 +6,7 @@ For detailed guidance on writing and reviewing code and documentation, see the s
|
||||
|
||||
## Clojure
|
||||
|
||||
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over `clojure -X:...` shell commands.
|
||||
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over shell commands.
|
||||
- **[clojure-write](.claude/skills/clojure-write/SKILL.md)** - Clojure/ClojureScript development with REPL-driven workflow and coding conventions
|
||||
- **[clojure-review](.claude/skills/clojure-review/SKILL.md)** - Clojure/ClojureScript code review guidelines and style enforcement
|
||||
|
||||
|
||||
@@ -10,15 +10,30 @@
|
||||
# Usage:
|
||||
# bin/test-agent :only '[metabase.foo-test]'
|
||||
# bin/test-agent :only '[metabase.foo-test/some-test]'
|
||||
# bin/test-agent --oss :only '[metabase.foo-test]' # run without EE extensions
|
||||
# bin/test-agent # runs all tests (rarely what you want)
|
||||
#
|
||||
# Any extra arguments are forwarded directly to `clojure -X:dev:ee:ee-dev:test`.
|
||||
# Options:
|
||||
# --oss Run without enterprise extensions (uses :oss:oss-dev instead of :ee:ee-dev)
|
||||
#
|
||||
# Any extra arguments are forwarded directly to the clojure test command.
|
||||
# The :test alias uses port 0 (OS-assigned random port) so parallel runs never collide.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export HAWK_MODE=cli/ci
|
||||
|
||||
# Check for --oss flag
|
||||
ALIASES=":dev:ee:ee-dev:test"
|
||||
args=()
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--oss" ]]; then
|
||||
ALIASES=":dev:ci:test:oss:oss-dev"
|
||||
else
|
||||
args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
# Filter noise and strip ANSI escape sequences
|
||||
clean_output() {
|
||||
grep -v '^Downloading: ' \
|
||||
@@ -28,4 +43,4 @@ clean_output() {
|
||||
| sed $'s/\033[\[(][0-9;]*[A-Za-z]//g; s/\033[^[]*//g'
|
||||
}
|
||||
|
||||
exec clojure -X:dev:ee:ee-dev:test "$@" 2>&1 | clean_output
|
||||
exec clojure -X"${ALIASES}" "${args[@]}" 2>&1 | clean_output
|
||||
|
||||
1
cross-version/.gitignore
vendored
Normal file
1
cross-version/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.xv-*
|
||||
@@ -1,55 +1,70 @@
|
||||
# Cross-Version Migration Testing
|
||||
|
||||
Tests Metabase database migrations across version upgrades and downgrades.
|
||||
Tests that Metabase data survives database migrations across version upgrades and downgrades.
|
||||
|
||||
## Quick Start
|
||||
Uses Cypress e2e tests to create data on a **source** version, then verify it's intact on a **target** version after migration. The application database is PostgreSQL (matching production), and state is preserved between versions via a Docker volume.
|
||||
|
||||
## Scripts
|
||||
|
||||
### `test.sh` — CI / production runs
|
||||
|
||||
Runs the full migration test end-to-end. Starts source, runs `@source` specs, stops it, starts target, runs `@target` specs.
|
||||
|
||||
```bash
|
||||
# Test upgrade (OSS)
|
||||
./test.sh --source v0.58.6 --target v0.58.7
|
||||
|
||||
# Test downgrade (OSS)
|
||||
./test.sh --source v0.58.7 --target v0.58.6
|
||||
|
||||
# Test Enterprise edition (use v1.x.x)
|
||||
./test.sh --source v1.58.6 --target v1.58.7
|
||||
./test.sh --source v1.57.6 --target v1.58.3 # upgrade
|
||||
./test.sh --source v1.58.3 --target v1.57.6 # downgrade
|
||||
```
|
||||
|
||||
Edition is inferred from version prefix: `v0.x.x` = OSS, `v1.x.x` = EE.
|
||||
We only test Enterprise Edition (`v1.x`) for now. Direction is inferred from version comparison.
|
||||
|
||||
## Requirements
|
||||
For **downgrades**, the script expects the target to refuse startup, then runs cascading `migrate down` (one step per major version) before retrying.
|
||||
|
||||
- Docker
|
||||
- Docker Compose v2
|
||||
- Bun (for version helpers CLI)
|
||||
### `dev.sh` — local development
|
||||
|
||||
## How It Works
|
||||
Starts a single Metabase version in Docker with H2 and opens Cypress interactively. Snapshot/restore works via `/api/testing/*` endpoints, controlled by the `CROSS_VERSION_DEV_MODE` env var.
|
||||
|
||||
### Upgrade Test
|
||||
1. Start PostgreSQL container
|
||||
2. Start SOURCE Metabase version, wait for healthy
|
||||
3. Stop SOURCE container (PostgreSQL persists)
|
||||
4. Start TARGET version - migrations run automatically
|
||||
5. Verify health check passes
|
||||
```bash
|
||||
./dev.sh --version v1.57.6
|
||||
./dev.sh --version v1.58.3 --port 3001
|
||||
```
|
||||
|
||||
### Downgrade Test
|
||||
1. Start PostgreSQL container
|
||||
2. Start SOURCE (newer) Metabase version, wait for healthy
|
||||
3. Stop SOURCE container
|
||||
4. Start TARGET (older) version - expects refusal
|
||||
5. Run `migrate down` command
|
||||
6. Start TARGET version again
|
||||
7. Verify health check passes
|
||||
## E2E test specs
|
||||
|
||||
## Environment Variables
|
||||
Specs live in `e2e/cross-version/` and are organized by major version:
|
||||
|
||||
```
|
||||
e2e/cross-version/
|
||||
56/ # specs for v56
|
||||
57/ # specs for v57
|
||||
latest/ # specs for HEAD / newest versions
|
||||
```
|
||||
|
||||
Each spec file uses `@source` and `@target` tags (via `cypress-grep`) to separate setup from verification. Each folder also contains a `helpers.ts` with version-specific utility functions used by the specs.
|
||||
|
||||
### When to create a new versioned folder
|
||||
|
||||
The `latest/` folder is the working copy — it targets the current development branch and is used for local dev with `dev.sh`. When a Metabase release introduces a breaking change that makes existing specs fail on older versions (e.g., a renamed UI element, a changed API response), copy `latest/` into a new numbered folder (e.g., `58/`). That snapshot covers all future versions until the next breaking change requires a new folder.
|
||||
|
||||
In short: versioned folders are frozen snapshots, `latest/` is the living version.
|
||||
|
||||
### Spec resolution
|
||||
|
||||
Both `test.sh` and `dev.sh` resolve which spec folder to use for a given version:
|
||||
|
||||
1. **Exact match** — `e2e/cross-version/{major}/` if it exists
|
||||
2. **Closest newer** — the lowest numbered folder >= the major version
|
||||
3. **Fallback** — `e2e/cross-version/latest/`
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `METABASE_PORT` | 3000 | Port to expose Metabase |
|
||||
| `HEALTH_TIMEOUT` | 120 | Seconds to wait for health check |
|
||||
| `METABASE_PORT` | `3000` (`3077` in dev) | Port to expose Metabase |
|
||||
| `HEALTH_TIMEOUT` | `120` | Seconds to wait for health check |
|
||||
| `CROSS_VERSION_DEV_MODE` | — | Set automatically by `dev.sh`; enables snapshot/restore helpers in specs |
|
||||
|
||||
## CI
|
||||
## Requirements
|
||||
|
||||
The GitHub workflow (`.github/workflows/cross-version.yml`) supports:
|
||||
- **Manual trigger**: Specify source/target versions
|
||||
- **Nightly schedule**: Runs predefined version matrix
|
||||
- Docker and Docker Compose v2
|
||||
- Bun (for `cli.ts` version helpers)
|
||||
- Node.js with `bunx` (for Cypress)
|
||||
|
||||
167
cross-version/dev.sh
Executable file
167
cross-version/dev.sh
Executable file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Interactive dev environment for writing cross-version e2e tests.
|
||||
#
|
||||
# Starts an older Metabase version in Docker with H2 and opens Cypress.
|
||||
# Uses H2 with a shared volume so snapshot/restore works via test endpoints.
|
||||
#
|
||||
# Resolves which spec folder to use:
|
||||
# e2e/cross-version/{major}/ if it exists, otherwise e2e/cross-version/latest/
|
||||
#
|
||||
# Usage:
|
||||
# ./dev.sh --version v1.57.6
|
||||
# ./dev.sh --version v1.58.7 --port 3001
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
VERSION=""
|
||||
PORT="${METABASE_PORT:-3077}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[xv-dev]${NC} $*"; }
|
||||
error() { echo -e "${RED}[xv-dev]${NC} $*" >&2; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --version VERSION [--port PORT]
|
||||
|
||||
Options:
|
||||
--version VERSION Metabase version to run (e.g., v1.57.6, v1.58.7)
|
||||
--port PORT Port to expose (default: 3077)
|
||||
--help Show this help
|
||||
|
||||
Examples:
|
||||
$0 --version v1.57.6
|
||||
$0 --version v1.58.7 --port 3001
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--help) usage ;;
|
||||
*) error "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
error "--version is required"
|
||||
usage
|
||||
fi
|
||||
|
||||
# CLI wrapper for version helpers
|
||||
cli() {
|
||||
bun "$SCRIPT_DIR/cli.ts" "$@"
|
||||
}
|
||||
|
||||
IMAGE=$(cli image "$VERSION")
|
||||
MAJOR=$(cli major "$VERSION")
|
||||
H2_DIR="$SCRIPT_DIR/.xv-h2/v${MAJOR}"
|
||||
LOG_FILE="$SCRIPT_DIR/.xv-metabase.log"
|
||||
|
||||
# Resolve spec folder: exact match, then closest older-version folder, then latest
|
||||
SPECS_DIR="$REPO_ROOT/e2e/cross-version/${MAJOR}"
|
||||
if [[ ! -d "$SPECS_DIR" ]]; then
|
||||
closest=""
|
||||
closest_dist=999
|
||||
for dir in "$REPO_ROOT"/e2e/cross-version/[0-9]*/; do
|
||||
v=$(basename "$dir")
|
||||
(( v < MAJOR )) && continue
|
||||
dist=$(( v - MAJOR ))
|
||||
if (( dist < closest_dist )); then
|
||||
closest_dist=$dist
|
||||
closest="$dir"
|
||||
fi
|
||||
done
|
||||
if [[ -n "$closest" ]]; then
|
||||
SPECS_DIR="${closest%/}"
|
||||
else
|
||||
SPECS_DIR="$REPO_ROOT/e2e/cross-version/latest"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "============================================"
|
||||
log "Cross-Version Dev Environment"
|
||||
log "============================================"
|
||||
log "Version: $VERSION"
|
||||
log "Image: $IMAGE"
|
||||
log "Port: $PORT"
|
||||
log "H2 dir: $H2_DIR"
|
||||
log "Logs: $LOG_FILE"
|
||||
log "Specs: $SPECS_DIR"
|
||||
log "============================================"
|
||||
|
||||
# Clean up previous session
|
||||
rm -f "$LOG_FILE"
|
||||
|
||||
# Start with a clean app db every time
|
||||
# H2 files are owned by the Docker container's user, so use Docker to clean up
|
||||
if [[ -d "$H2_DIR" ]]; then
|
||||
docker run --rm -v "$(dirname "$H2_DIR"):/data" alpine rm -rf "/data/$(basename "$H2_DIR")"
|
||||
fi
|
||||
mkdir -p "$H2_DIR"
|
||||
|
||||
cleanup() {
|
||||
log "Stopping Metabase..."
|
||||
docker rm -f xv-dev-metabase 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Remove any leftover container from a previous run (e.g., after SIGKILL)
|
||||
docker rm -f xv-dev-metabase 2>/dev/null || true
|
||||
|
||||
log "Starting Metabase ${VERSION}..."
|
||||
docker run -d \
|
||||
--name xv-dev-metabase \
|
||||
-p "${PORT}:3000" \
|
||||
-v "${H2_DIR}:/metabase.db" \
|
||||
-e MB_ENABLE_TEST_ENDPOINTS=true \
|
||||
-e MB_DANGEROUS_UNSAFE_ENABLE_TESTING_H2_CONNECTIONS_DO_NOT_ENABLE=true \
|
||||
"$IMAGE"
|
||||
|
||||
# Stream backend logs to file for debugging (docker logs xv-dev-metabase)
|
||||
docker logs -f xv-dev-metabase > "$LOG_FILE" 2>&1 &
|
||||
|
||||
# Create snapshots dir inside the container for /api/testing/snapshot and /api/testing/restore
|
||||
docker exec xv-dev-metabase sh -c "mkdir -p /e2e/snapshots && chmod 777 /e2e/snapshots"
|
||||
|
||||
log "Waiting for Metabase to be ready..."
|
||||
TIMEOUT=120
|
||||
START=$(date +%s)
|
||||
while true; do
|
||||
ELAPSED=$(( $(date +%s) - START ))
|
||||
if (( ELAPSED >= TIMEOUT )); then
|
||||
error "Timed out after ${TIMEOUT}s"
|
||||
docker logs xv-dev-metabase
|
||||
exit 1
|
||||
fi
|
||||
if curl -sf "http://localhost:${PORT}/api/health" 2>/dev/null | grep -q '"ok"'; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
log "Metabase is ready at http://localhost:${PORT}"
|
||||
|
||||
log "Saving blank snapshot..."
|
||||
curl -sf -X POST "http://localhost:${PORT}/api/testing/snapshot/blank"
|
||||
|
||||
log ""
|
||||
log "Opening Cypress..."
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
CROSS_VERSION_DEV_MODE=true \
|
||||
CYPRESS_BASE_URL="http://localhost:${PORT}" \
|
||||
bunx cypress open \
|
||||
--e2e \
|
||||
--config-file "e2e/cross-version/cypress.config.js" \
|
||||
--config "specPattern=${SPECS_DIR}/**/*.cy.spec.ts"
|
||||
@@ -11,6 +11,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Defaults
|
||||
@@ -18,6 +19,7 @@ SOURCE_VERSION=""
|
||||
TARGET_VERSION=""
|
||||
METABASE_PORT="${METABASE_PORT:-3000}"
|
||||
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-120}"
|
||||
RESULT_FILE="${RESULT_FILE:-/tmp/cross-version-result.json}"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
@@ -29,6 +31,17 @@ log() { echo -e "${GREEN}[cross-version]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[cross-version]${NC} $*"; }
|
||||
error() { echo -e "${RED}[cross-version]${NC} $*" >&2; }
|
||||
|
||||
# Write a structured result file on failure so CI can categorize the failure
|
||||
write_failure() {
|
||||
local phase="$1" # "migration" or "e2e"
|
||||
jq -n \
|
||||
--arg phase "$phase" \
|
||||
--arg source "$SOURCE_VERSION" \
|
||||
--arg target "$TARGET_VERSION" \
|
||||
'{phase:$phase, source:$source, target:$target}' > "$RESULT_FILE"
|
||||
log "Wrote failure result to $RESULT_FILE"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --source VERSION --target VERSION
|
||||
@@ -66,6 +79,41 @@ cli() {
|
||||
bun "$SCRIPT_DIR/cli.ts" "$@"
|
||||
}
|
||||
|
||||
run_e2e() {
|
||||
local phase="$1"
|
||||
local version="$2"
|
||||
local major specs_dir
|
||||
|
||||
major=$(cli major "$version")
|
||||
specs_dir="$REPO_ROOT/e2e/cross-version/${major}"
|
||||
if [[ ! -d "$specs_dir" ]]; then
|
||||
local closest="" closest_dist=999
|
||||
for dir in "$REPO_ROOT"/e2e/cross-version/[0-9]*/; do
|
||||
local v=$(basename "$dir")
|
||||
(( v < major )) && continue
|
||||
local dist=$(( v - major ))
|
||||
if (( dist < closest_dist )); then
|
||||
closest_dist=$dist
|
||||
closest="$dir"
|
||||
fi
|
||||
done
|
||||
if [[ -n "$closest" ]]; then
|
||||
specs_dir="${closest%/}"
|
||||
log "No exact specs for v${major} — using closest: ${specs_dir##*/}/"
|
||||
else
|
||||
specs_dir="$REPO_ROOT/e2e/cross-version/latest"
|
||||
log "No exact specs for v${major} — falling back to latest/"
|
||||
fi
|
||||
else
|
||||
log "Found exact specs for v${major}"
|
||||
fi
|
||||
|
||||
log "Running @${phase} e2e tests for ${version} from: e2e/cross-version/${specs_dir##*/}/"
|
||||
CYPRESS_SPEC_PATTERN="${specs_dir}/**/*.cy.spec.ts" \
|
||||
"$REPO_ROOT/e2e/cross-version/run.sh" --phase "$phase"
|
||||
}
|
||||
|
||||
|
||||
wait_for_health() {
|
||||
local timeout="$1"
|
||||
local start_time=$(date +%s)
|
||||
@@ -194,6 +242,11 @@ migrate_down_step() {
|
||||
error "migrate down encountered errors (check logs above)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if echo "$output" | grep -q "not rolled back"; then
|
||||
error "migrate down had changesets that were not rolled back (check logs above)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Cascading migrate down: rolls back multiple major versions one at a time
|
||||
@@ -319,17 +372,25 @@ main() {
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ SOURCE version ($SOURCE_VERSION) failed health check"
|
||||
docker compose logs metabase
|
||||
write_failure "migration"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "✅ SOURCE version ($SOURCE_VERSION) is healthy"
|
||||
|
||||
log ""
|
||||
log "Step 2: Stopping SOURCE version ($SOURCE_VERSION)..."
|
||||
log "Step 2: Running e2e tests (@source)..."
|
||||
if ! run_e2e source "$SOURCE_VERSION"; then
|
||||
write_failure "e2e"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "Step 3: Stopping SOURCE version ($SOURCE_VERSION)..."
|
||||
stop_metabase
|
||||
|
||||
log ""
|
||||
log "Step 3: Starting TARGET version ($TARGET_VERSION)..."
|
||||
log "Step 4: Starting TARGET version ($TARGET_VERSION)..."
|
||||
start_metabase "$target_image"
|
||||
|
||||
if [[ "$direction" == "upgrade" ]]; then
|
||||
@@ -337,15 +398,24 @@ main() {
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) failed health check after upgrade"
|
||||
docker compose logs metabase
|
||||
write_failure "migration"
|
||||
exit 1
|
||||
fi
|
||||
log "✅ UPGRADE successful - TARGET version ($TARGET_VERSION) is healthy"
|
||||
|
||||
log ""
|
||||
log "Step 5: Running e2e tests (@target)..."
|
||||
if ! run_e2e target "$TARGET_VERSION"; then
|
||||
write_failure "e2e"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
else
|
||||
# Downgrade: should refuse to start, then we run migrate down
|
||||
if ! check_downgrade_refused; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) did not properly detect downgrade"
|
||||
docker compose logs metabase
|
||||
write_failure "migration"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -353,25 +423,32 @@ main() {
|
||||
stop_metabase
|
||||
|
||||
log ""
|
||||
log "============================================"
|
||||
log "Step 4: Rolling back database ($SOURCE_VERSION → $TARGET_VERSION)..."
|
||||
log "============================================"
|
||||
log "Step 5: Rolling back database ($SOURCE_VERSION → $TARGET_VERSION)..."
|
||||
if ! cascading_migrate_down "$SOURCE_VERSION" "$TARGET_VERSION"; then
|
||||
error "❌ migrate down failed"
|
||||
write_failure "migration"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Try starting again
|
||||
log ""
|
||||
log "Step 5: Starting TARGET version ($TARGET_VERSION) after migrate down..."
|
||||
log "Step 6: Starting TARGET version ($TARGET_VERSION) after migrate down..."
|
||||
start_metabase "$target_image"
|
||||
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) failed health check after migrate down"
|
||||
docker compose logs metabase
|
||||
write_failure "migration"
|
||||
exit 1
|
||||
fi
|
||||
log "✅ DOWNGRADE successful - TARGET version ($TARGET_VERSION) is healthy after migrate down"
|
||||
|
||||
log ""
|
||||
log "Step 7: Running e2e tests (@target)..."
|
||||
if ! run_e2e target "$TARGET_VERSION"; then
|
||||
write_failure "e2e"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log ""
|
||||
|
||||
@@ -15,9 +15,13 @@ Two optional config keys per module in
|
||||
| `:model-imports` | `#{}` | Set of `:model/X` keywords **this module** may use from other modules. |
|
||||
|
||||
When omitted, no models are exported/imported (closed by default). `:any`
|
||||
opens all restrictions. Every module has explicit `:model-exports` and
|
||||
`:model-imports` in `config.edn` — the defaults apply only to new modules
|
||||
that haven't been configured yet.
|
||||
opens all restrictions.
|
||||
|
||||
`:model-imports` also supports `:bypass` for cross-cutting modules that
|
||||
reference nearly every model (e.g. `cmd`, `enterprise/serialization`). A
|
||||
bypassed module can reference any model — even unexported ones. Models that
|
||||
are *only* referenced by bypass modules do not need `:model-exports` entries
|
||||
(the staleness test will flag them if added).
|
||||
|
||||
## Why it exists
|
||||
|
||||
@@ -120,6 +124,24 @@ The module can only reference these models from other modules. Any other
|
||||
`:model/X` keyword in the module's source files will cause a test failure.
|
||||
Unknown models (not defined anywhere) are also violations.
|
||||
|
||||
### Bypassing import restrictions
|
||||
|
||||
Some modules are inherently cross-cutting and import nearly every model. Use
|
||||
`:bypass` instead of an explicit set:
|
||||
|
||||
```clojure
|
||||
cmd
|
||||
{:team "UX West"
|
||||
:model-imports :bypass
|
||||
...}
|
||||
```
|
||||
|
||||
A bypassed module can reference any model, even unexported ones. This means
|
||||
bypass modules don't drive `:model-exports` — if a model is *only* used
|
||||
outside its home module by bypass modules, it doesn't need to be exported.
|
||||
|
||||
Use sparingly. Prefer explicit sets where feasible.
|
||||
|
||||
### Verifying changes
|
||||
|
||||
```bash
|
||||
@@ -143,8 +165,8 @@ externally, imports not referenced in the module's source files).
|
||||
Use the dev namespace to see what the config should contain:
|
||||
|
||||
```clojure
|
||||
(dev.model-boundary-config/compute-and-print!) ;; print all exports/imports
|
||||
(dev.model-boundary-config/compute-model-boundaries) ;; returns data
|
||||
(dev.model-boundary-config/update-config!) ;; rewrite config.edn
|
||||
```
|
||||
|
||||
## Limitations and future directions
|
||||
|
||||
@@ -826,25 +826,31 @@
|
||||
#{'metabase.models.resolution})
|
||||
|
||||
(defn- model-reference-violations
|
||||
"Return violation types for a single model reference. Pure function — no IO."
|
||||
"Return violation types for a single model reference. Pure function — no IO.
|
||||
|
||||
When `model-imports` is `:bypass`, the using module is exempt from all model boundary checks —
|
||||
it may reference any model regardless of whether it is exported."
|
||||
[model defining-mod model-exports model-imports]
|
||||
(cond-> []
|
||||
(nil? defining-mod)
|
||||
(conj :unknown-model)
|
||||
(if (= model-imports :bypass)
|
||||
[]
|
||||
(cond-> []
|
||||
(nil? defining-mod)
|
||||
(conj :unknown-model)
|
||||
|
||||
(and defining-mod
|
||||
(not= model-exports :any)
|
||||
(not (contains? model-exports model)))
|
||||
(conj :not-exported)
|
||||
(and defining-mod
|
||||
(not= model-exports :any)
|
||||
(not (contains? model-exports model)))
|
||||
(conj :not-exported)
|
||||
|
||||
(and defining-mod
|
||||
(not= model-imports :any)
|
||||
(not (contains? model-imports model)))
|
||||
(conj :not-imported)))
|
||||
(and defining-mod
|
||||
(not= model-imports :any)
|
||||
(not (contains? model-imports model)))
|
||||
(conj :not-imported))))
|
||||
|
||||
(defn model-references-by-module
|
||||
"Scan all source files and build a map of `{module => #{:model/X ...}}` — the set of model keywords
|
||||
referenced in each module's source files. Exempt namespaces (e.g. `metabase.models.resolution`) are excluded."
|
||||
referenced in each module's source files. Exempt namespaces (e.g. `metabase.models.resolution`) are excluded.
|
||||
Includes all modules (including bypass modules) — callers filter as needed."
|
||||
[]
|
||||
(reduce
|
||||
(fn [acc file]
|
||||
@@ -872,6 +878,9 @@
|
||||
2. The using module's `:model-imports` allows the model (`:any` or set containing it)
|
||||
3. The model's definition exists somewhere (`:unknown-model` is always a violation)
|
||||
|
||||
Modules with `:model-imports :bypass` are exempt from all checks — they may reference any model,
|
||||
even unexported ones.
|
||||
|
||||
Returns a sequence of violation maps with `:file`, `:module`, `:model`, `:defining-module`, `:violation-type`."
|
||||
([]
|
||||
(model-boundary-violations (kondo-config)))
|
||||
|
||||
@@ -5,9 +5,16 @@
|
||||
|
||||
(dev.model-boundary-config/compute-model-boundaries)
|
||||
;; => {:model-exports {module => #{:model/X ...}}
|
||||
;; :model-imports {module => #{:model/X ...}}}"
|
||||
;; :model-imports {module => #{:model/X ...}}}
|
||||
|
||||
To update config.edn with computed model boundaries:
|
||||
|
||||
(dev.model-boundary-config/update-config!)"
|
||||
(:require
|
||||
[dev.deps-graph :as deps-graph]))
|
||||
[clojure.string :as str]
|
||||
[dev.deps-graph :as deps-graph]
|
||||
[rewrite-clj.parser :as r.parser]
|
||||
[rewrite-clj.zip :as z]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -17,19 +24,31 @@
|
||||
Returns `{:model-exports {module => sorted-set-of-models}
|
||||
:model-imports {module => sorted-set-of-models}}`
|
||||
|
||||
`:model-exports` for module M = models owned by M that are referenced by at least one other module.
|
||||
`:model-imports` for module M = models owned by other modules that M references."
|
||||
`:model-exports` for module M = models owned by M that are referenced by at least one non-bypass module.
|
||||
`:model-imports` for module M = models owned by other modules that M references.
|
||||
|
||||
Modules with `:model-imports :bypass` are excluded: they don't need computed imports, and their
|
||||
references don't drive exports (models used only by bypass modules need not be exported)."
|
||||
[]
|
||||
(let [ownership (deps-graph/model-ownership)
|
||||
(let [config (deps-graph/kondo-config)
|
||||
ownership (deps-graph/model-ownership)
|
||||
module-refs (deps-graph/model-references-by-module)
|
||||
all-modules (set (keys (deps-graph/kondo-config)))
|
||||
all-modules (set (keys config))
|
||||
bypass-modules (into #{}
|
||||
(keep (fn [[mod mod-config]]
|
||||
(when (= (:model-imports mod-config) :bypass)
|
||||
mod)))
|
||||
config)
|
||||
;; {:model/X => #{modules that reference it}} — inverted index for fast export lookups
|
||||
;; Only non-bypass modules drive exports.
|
||||
model->referencing-modules
|
||||
(reduce-kv (fn [acc mod models]
|
||||
(reduce (fn [acc model]
|
||||
(update acc model (fnil conj #{}) mod))
|
||||
acc
|
||||
models))
|
||||
(if (contains? bypass-modules mod)
|
||||
acc
|
||||
(reduce (fn [acc model]
|
||||
(update acc model (fnil conj #{}) mod))
|
||||
acc
|
||||
models)))
|
||||
{}
|
||||
module-refs)]
|
||||
{:model-exports
|
||||
@@ -45,6 +64,7 @@
|
||||
:model-imports
|
||||
(into (sorted-map)
|
||||
(for [mod all-modules
|
||||
:when (not (contains? bypass-modules mod))
|
||||
:let [imported (into (sorted-set)
|
||||
(for [model (get module-refs mod)
|
||||
:let [defining-mod (get ownership model)]
|
||||
@@ -52,3 +72,70 @@
|
||||
model))]
|
||||
:when (seq imported)]
|
||||
[mod imported]))}))
|
||||
|
||||
(def ^:private config-path ".clj-kondo/config/modules/config.edn")
|
||||
|
||||
(def ^:private set-indent
|
||||
"Indentation for multi-line model sets — aligns with existing config.edn style."
|
||||
(apply str (repeat 20 \space)))
|
||||
|
||||
(defn- model-set-str
|
||||
"Build a string representation of a sorted set of model keywords.
|
||||
Short sets (<=3 items) stay on one line; longer sets get one item per line."
|
||||
[models]
|
||||
(let [sorted (sort models)]
|
||||
(if (<= (count sorted) 3)
|
||||
(str "#{" (str/join " " sorted) "}")
|
||||
(str "#{" (str/join (str "\n" set-indent) sorted) "}"))))
|
||||
|
||||
(defn- find-module-config
|
||||
"Navigate from `root` to the config map for `module-sym`. Returns a zipper or nil."
|
||||
[root module-sym]
|
||||
(let [mods-zloc (-> (z/of-node root)
|
||||
(z/find-value z/next :metabase/modules)
|
||||
z/right)]
|
||||
(z/find-value (z/down mods-zloc) z/right module-sym)))
|
||||
|
||||
(defn- find-key-value
|
||||
"Find the value zipper for keyword `k` inside a map zipper, or nil."
|
||||
[map-zloc k]
|
||||
(some-> (z/find-value (z/down map-zloc) z/right k)
|
||||
z/right))
|
||||
|
||||
(defn update-config!
|
||||
"Compute model boundaries and update config.edn with `:model-exports` and `:model-imports` for all modules.
|
||||
|
||||
Only updates keys that already exist in the config — does not add new keys.
|
||||
Uses rewrite-clj to manipulate the AST directly, preserving formatting."
|
||||
[]
|
||||
(let [boundaries (compute-model-boundaries)
|
||||
root-zloc (z/of-node (r.parser/parse-file-all config-path))
|
||||
modules-zloc (-> root-zloc
|
||||
(z/find-value z/next :metabase/modules)
|
||||
z/right)
|
||||
module-syms (loop [zloc (z/down modules-zloc), acc []]
|
||||
(if-not zloc
|
||||
acc
|
||||
(recur (some-> zloc z/right z/right)
|
||||
(conj acc (z/sexpr zloc)))))
|
||||
updated-root
|
||||
(reduce
|
||||
(fn [root module-sym]
|
||||
(reduce
|
||||
(fn [root config-key]
|
||||
(let [mod-cfg (some-> (find-module-config root module-sym) z/right)
|
||||
val-zloc (when mod-cfg (find-key-value mod-cfg config-key))]
|
||||
;; Only update keys that already exist and are sets (skip :bypass, :any, etc.)
|
||||
(if (and val-zloc (set? (z/sexpr val-zloc)))
|
||||
(let [computed (get-in boundaries [config-key module-sym] #{})]
|
||||
(if (seq computed)
|
||||
(z/root (z/replace val-zloc (r.parser/parse-string (model-set-str computed))))
|
||||
;; Empty — remove the key-value pair
|
||||
(-> val-zloc z/remove z/remove z/root)))
|
||||
root)))
|
||||
root
|
||||
[:model-exports :model-imports]))
|
||||
(z/root root-zloc)
|
||||
module-syms)]
|
||||
(spit config-path (z/root-string (z/of-node updated-root)))
|
||||
(println "Updated" config-path "with :model-exports and :model-imports.")))
|
||||
|
||||
532
dev/src/dev/mq_perf.clj
Normal file
532
dev/src/dev/mq_perf.clj
Normal file
@@ -0,0 +1,532 @@
|
||||
(ns dev.mq-perf
|
||||
"Performance benchmarks for the appdb-backed MQ system (queues and topics).
|
||||
|
||||
Measures publishing throughput, end-to-end latency, and sustained throughput
|
||||
under different load levels. Benchmarks exercise the full realistic pipeline:
|
||||
publish buffer → DB → poll thread → worker pool → listener.
|
||||
|
||||
Self-contained — starts the MQ subsystem without the full Metabase server.
|
||||
Supports multi-node testing: run multiple JVM instances against the same DB.
|
||||
|
||||
Example usage:
|
||||
;; Single-node: start MQ and run all benchmarks
|
||||
(start-mq!)
|
||||
(run-all-benchmarks!)
|
||||
|
||||
;; Multi-node coordination: start multiple JVMs, each calls:
|
||||
(start-mq!)
|
||||
(run-all-benchmarks-coordinated!)
|
||||
;; Then from any node (or a separate REPL), publish the go signal:
|
||||
(signal-go!)
|
||||
|
||||
;; Individual benchmarks
|
||||
(bench-publish-throughput! {:n 1000 :batch-sizes [1 10 100]})
|
||||
(bench-e2e-latency! {:n 50 :rate :medium :transport :queue})
|
||||
(bench-sustained-throughput! {:duration-sec 15 :rate :medium :transport :queue})
|
||||
(bench-batch-listener! {:n 200 :batch-sizes [1 10 50]})
|
||||
(bench-multi-queue! {:queue-counts [1 5 10 20] :msgs-per-queue 50})"
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.mq.core :as mq]
|
||||
[metabase.mq.listener :as listener]
|
||||
[metabase.mq.publish-buffer :as publish-buffer]
|
||||
[metabase.mq.queue.backend :as q.backend]
|
||||
[metabase.mq.topic.backend :as topic.backend]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.util.concurrent CountDownLatch TimeUnit)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; ------------------------------------------------ Startup ------------------------------------------------
|
||||
|
||||
(defonce ^:private started? (atom false))
|
||||
|
||||
(defn start-mq!
|
||||
"Minimal startup: connects to app DB and starts the MQ subsystem.
|
||||
No Jetty, no scheduler, no sample data. Idempotent."
|
||||
[]
|
||||
(let [setup-db! (requiring-resolve 'metabase.app-db.core/setup-db!)]
|
||||
(setup-db! :create-sample-content? false))
|
||||
(let [setup-prometheus! (requiring-resolve 'metabase.analytics.prometheus/setup!)]
|
||||
(setup-prometheus!))
|
||||
(require 'metabase.mq.init)
|
||||
(let [startup! (requiring-resolve 'metabase.startup.core/def-startup-logic!)]
|
||||
(startup! :metabase.mq.init/MqInit))
|
||||
(reset! started? true)
|
||||
(println "MQ system started."))
|
||||
|
||||
(defn- ensure-started!
|
||||
"Starts the MQ system if not yet started, and restarts any dead poll threads."
|
||||
[]
|
||||
(when-not @started?
|
||||
(start-mq!))
|
||||
;; Restart poll threads if they died (e.g., from InterruptedException during a previous benchmark).
|
||||
;; start! is idempotent and start-polling! detects dead futures.
|
||||
(let [start-transports! (requiring-resolve 'metabase.mq.impl/start-transports)]
|
||||
(start-transports!)))
|
||||
|
||||
;;; ------------------------------------------------ Utilities ------------------------------------------------
|
||||
|
||||
(def ^:private shared-queues
|
||||
"Fixed set of 10 shared queue names used across all nodes for cross-node contention testing."
|
||||
(mapv #(keyword "queue" (str "bench-shared-" %)) (range 10)))
|
||||
|
||||
(def ^:private shared-topic :topic/bench-shared)
|
||||
|
||||
(defn- run-id
|
||||
"Generates an 8-character unique identifier for a benchmark run."
|
||||
[]
|
||||
(subs (str (random-uuid)) 0 8))
|
||||
|
||||
(defn- make-channel
|
||||
"Creates a unique channel keyword for benchmarking."
|
||||
[transport id suffix]
|
||||
(keyword (name transport) (str "bench-" id "-" suffix)))
|
||||
|
||||
(defn- random-shared-queue
|
||||
"Returns a random shared queue name."
|
||||
[]
|
||||
(nth shared-queues (rand-int (count shared-queues))))
|
||||
|
||||
(defn- percentile
|
||||
"Returns the p-th percentile from a sorted vector (p in [0,1])."
|
||||
[sorted-vec p]
|
||||
(if (empty? sorted-vec)
|
||||
0.0
|
||||
(let [idx (min (dec (count sorted-vec))
|
||||
(int (* p (count sorted-vec))))]
|
||||
(double (nth sorted-vec idx)))))
|
||||
|
||||
(defn- collect-stats
|
||||
"Computes summary statistics from a collection of numeric values.
|
||||
Returns {:count :min :max :avg :p50 :p95 :p99}."
|
||||
[values]
|
||||
(if (empty? values)
|
||||
{:count 0 :min 0.0 :max 0.0 :avg 0.0 :p50 0.0 :p95 0.0 :p99 0.0}
|
||||
(let [sorted (vec (sort values))
|
||||
n (count sorted)]
|
||||
{:count n
|
||||
:min (double (first sorted))
|
||||
:max (double (last sorted))
|
||||
:avg (/ (reduce + 0.0 sorted) n)
|
||||
:p50 (percentile sorted 0.50)
|
||||
:p95 (percentile sorted 0.95)
|
||||
:p99 (percentile sorted 0.99)})))
|
||||
|
||||
(defn- fmt
|
||||
"Formats a number for display."
|
||||
[x]
|
||||
(if (integer? x)
|
||||
(str x)
|
||||
(format "%.2f" (double x))))
|
||||
|
||||
(defn- print-table!
|
||||
"Prints a formatted ASCII table."
|
||||
[title headers rows]
|
||||
(let [col-widths (mapv (fn [i]
|
||||
(apply max
|
||||
(count (nth headers i))
|
||||
(map #(count (str (nth % i))) rows)))
|
||||
(range (count headers)))
|
||||
separator (str "+-" (str/join "-+-" (map #(apply str (repeat % \-)) col-widths)) "-+")
|
||||
fmt-row (fn [vals]
|
||||
(str "| "
|
||||
(str/join " | "
|
||||
(map-indexed (fn [i v]
|
||||
(format (str "%-" (nth col-widths i) "s") (str v)))
|
||||
vals))
|
||||
" |"))]
|
||||
(println)
|
||||
(println title)
|
||||
(println separator)
|
||||
(println (fmt-row headers))
|
||||
(println separator)
|
||||
(doseq [row rows]
|
||||
(println (fmt-row row)))
|
||||
(println separator)
|
||||
(println)))
|
||||
|
||||
(defn- cleanup!
|
||||
"Removes all benchmark data for the given run-id."
|
||||
[id]
|
||||
(t2/delete! :queue_message_batch :queue_name [:like (str "bench-" id "%")])
|
||||
(t2/delete! :topic_message_batch :topic_name [:like (str "bench-" id "%")])
|
||||
(doseq [[ch _] @listener/*listeners*]
|
||||
(when (str/includes? (name ch) (str "bench-" id))
|
||||
(mq/unlisten! ch))))
|
||||
|
||||
(defn- publish-at-rate!
|
||||
"Publishes `n` messages at the given rate (msgs/sec). `publish-fn` is called with each seq num.
|
||||
If rate is nil, publishes as fast as possible (burst mode)."
|
||||
[n rate publish-fn]
|
||||
(if (nil? rate)
|
||||
(dotimes [i n]
|
||||
(publish-fn i))
|
||||
(let [interval-ms (long (/ 1000 rate))]
|
||||
(dotimes [i n]
|
||||
(let [start (System/currentTimeMillis)]
|
||||
(publish-fn i)
|
||||
(let [elapsed (- (System/currentTimeMillis) start)
|
||||
sleep-ms (- interval-ms elapsed)]
|
||||
(when (pos? sleep-ms)
|
||||
(Thread/sleep (long sleep-ms)))))))))
|
||||
|
||||
(def ^:private rate-map
|
||||
{:low 10
|
||||
:medium 100
|
||||
:high 1000
|
||||
:burst nil})
|
||||
|
||||
(defn- rate->msgs-sec [rate]
|
||||
(get rate-map rate rate))
|
||||
|
||||
;;; ---------------------------------------- Benchmark 1: Publishing Throughput ----------------------------------------
|
||||
|
||||
(defn bench-publish-throughput!
|
||||
"Measures raw publishing speed for different batch sizes.
|
||||
This benchmark bypasses the publish buffer to measure direct DB insert speed.
|
||||
Options:
|
||||
:n - total messages to publish per batch-size test (default 1000)
|
||||
:batch-sizes - vector of batch sizes to test (default [1 10 100])
|
||||
:transport - :queue or :topic (default :queue)"
|
||||
([] (bench-publish-throughput! {}))
|
||||
([{:keys [n batch-sizes transport]
|
||||
:or {n 1000 batch-sizes [1 10 100] transport :queue}}]
|
||||
(ensure-started!)
|
||||
(let [id (run-id)
|
||||
results (atom [])]
|
||||
(try
|
||||
(doseq [bs batch-sizes]
|
||||
(let [channel (make-channel transport id (str "pub-" bs))
|
||||
iterations (/ n bs)
|
||||
start (System/nanoTime)]
|
||||
(binding [publish-buffer/*publish-buffer-ms* 0]
|
||||
(dotimes [i iterations]
|
||||
(let [msgs (vec (for [j (range bs)]
|
||||
{:seq (+ (* i bs) j) :ts (System/currentTimeMillis)}))]
|
||||
(if (= transport :queue)
|
||||
(q.backend/publish! :queue.backend/appdb channel msgs)
|
||||
(topic.backend/publish! :topic.backend/appdb channel msgs)))))
|
||||
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)]
|
||||
(swap! results conj
|
||||
[(str bs) (fmt (/ n (/ elapsed-ms 1000.0))) (fmt elapsed-ms)]))))
|
||||
(print-table!
|
||||
(str "Publishing Throughput (" (name transport) ", n=" n ")")
|
||||
["Batch Size" "Msgs/sec" "Total ms"]
|
||||
@results)
|
||||
(finally
|
||||
(cleanup! id))))))
|
||||
|
||||
;;; ---------------------------------------- Benchmark 2: End-to-End Latency ----------------------------------------
|
||||
|
||||
(defn bench-e2e-latency!
|
||||
"Measures end-to-end latency from publish to listener receipt via the full async pipeline.
|
||||
Uses the real publish API including the publish buffer.
|
||||
Publishes randomly to the 10 shared queues (or the shared topic) so multiple nodes
|
||||
compete for the same messages.
|
||||
Options:
|
||||
:n - number of messages to publish (default 50)
|
||||
:rate - :low, :medium, :high, or :burst (default :medium)
|
||||
:transport - :queue or :topic (default :queue)"
|
||||
([] (bench-e2e-latency! {}))
|
||||
([{:keys [n rate transport]
|
||||
:or {n 50 rate :medium transport :queue}}]
|
||||
(ensure-started!)
|
||||
(let [latencies (atom [])
|
||||
received (atom 0)
|
||||
channels (if (= transport :queue) shared-queues [shared-topic])]
|
||||
(try
|
||||
;; Register listeners on all shared channels
|
||||
(doseq [ch channels]
|
||||
(when-not (listener/get-listener ch)
|
||||
(mq/listen! ch {}
|
||||
(fn [msg]
|
||||
(let [receive-ns (System/nanoTime)
|
||||
publish-ns (get msg "publish-ns")]
|
||||
(when publish-ns
|
||||
(swap! latencies conj (/ (- receive-ns (double publish-ns)) 1e6))))
|
||||
(swap! received inc)))))
|
||||
(let [msgs-sec (rate->msgs-sec rate)
|
||||
start (System/nanoTime)]
|
||||
;; Publish to random shared channels
|
||||
(publish-at-rate!
|
||||
n msgs-sec
|
||||
(fn [i]
|
||||
(let [ch (if (= transport :queue) (random-shared-queue) shared-topic)]
|
||||
(if (= transport :queue)
|
||||
(mq/with-queue ch [q]
|
||||
(mq/put q {:seq i :publish-ns (System/nanoTime)}))
|
||||
(mq/with-topic ch [t]
|
||||
(mq/put t {:seq i :publish-ns (System/nanoTime)}))))))
|
||||
;; Force flush the publish buffer then wait for delivery
|
||||
(publish-buffer/flush-publish-buffer!)
|
||||
;; Wait until we've received at least n messages or timeout
|
||||
(let [deadline (+ (System/currentTimeMillis)
|
||||
(long (* 1000 (+ 30 (/ n (max 1 (or msgs-sec 1000)))))))]
|
||||
(while (and (< @received n)
|
||||
(< (System/currentTimeMillis) deadline))
|
||||
(Thread/sleep (long 100))))
|
||||
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)
|
||||
stats (collect-stats @latencies)]
|
||||
(print-table!
|
||||
(str "End-to-End Latency (" (name transport) ", rate=" (name rate) ", n=" n ")")
|
||||
["Metric" "Value"]
|
||||
[["Published" (str n)]
|
||||
["Received" (str @received)]
|
||||
["Other nodes" (str (max 0 (- n @received)))]
|
||||
["From others" (str (max 0 (- @received n)))]
|
||||
["Total ms" (fmt elapsed-ms)]
|
||||
["P50 ms" (fmt (:p50 stats))]
|
||||
["P95 ms" (fmt (:p95 stats))]
|
||||
["P99 ms" (fmt (:p99 stats))]
|
||||
["Min ms" (fmt (:min stats))]
|
||||
["Max ms" (fmt (:max stats))]
|
||||
["Avg ms" (fmt (:avg stats))]])))
|
||||
(finally
|
||||
(doseq [ch channels]
|
||||
(when (listener/get-listener ch)
|
||||
(mq/unlisten! ch))))))))
|
||||
|
||||
;;; ---------------------------------------- Benchmark 3: Sustained Throughput ----------------------------------------
|
||||
|
||||
(defn bench-sustained-throughput!
|
||||
"Publishes continuously while consuming for a given duration via the full async pipeline.
|
||||
Publishes randomly to the 10 shared queues (or shared topic).
|
||||
Options:
|
||||
:duration-sec - how long to publish (default 15)
|
||||
:rate - :low, :medium, :high, or :burst (default :medium)
|
||||
:transport - :queue or :topic (default :queue)"
|
||||
([] (bench-sustained-throughput! {}))
|
||||
([{:keys [duration-sec rate transport]
|
||||
:or {duration-sec 15 rate :medium transport :queue}}]
|
||||
(ensure-started!)
|
||||
(let [channels (if (= transport :queue) shared-queues [shared-topic])
|
||||
received (atom 0)
|
||||
latencies (atom [])
|
||||
published (atom 0)
|
||||
stop? (atom false)]
|
||||
(try
|
||||
;; Register listeners on all shared channels
|
||||
(doseq [ch channels]
|
||||
(when-not (listener/get-listener ch)
|
||||
(mq/listen! ch {}
|
||||
(fn [msg]
|
||||
(let [receive-ns (System/nanoTime)
|
||||
publish-ns (get msg "publish-ns")]
|
||||
(when publish-ns
|
||||
(swap! latencies conj (/ (- receive-ns (double publish-ns)) 1e6)))
|
||||
(swap! received inc))))))
|
||||
(let [msgs-sec (rate->msgs-sec rate)
|
||||
start (System/nanoTime)
|
||||
pub-thread (Thread.
|
||||
(fn []
|
||||
(loop []
|
||||
(when-not @stop?
|
||||
(try
|
||||
(let [ch (if (= transport :queue) (random-shared-queue) shared-topic)]
|
||||
(if (= transport :queue)
|
||||
(mq/with-queue ch [q]
|
||||
(mq/put q {:seq @published :publish-ns (System/nanoTime)}))
|
||||
(mq/with-topic ch [t]
|
||||
(mq/put t {:seq @published :publish-ns (System/nanoTime)}))))
|
||||
(swap! published inc)
|
||||
(catch Exception _))
|
||||
(when msgs-sec
|
||||
(Thread/sleep (long (/ 1000 msgs-sec))))
|
||||
(recur)))))]
|
||||
(.start pub-thread)
|
||||
(Thread/sleep (long (* duration-sec 1000)))
|
||||
(reset! stop? true)
|
||||
(.join pub-thread 5000)
|
||||
;; Flush publish buffer and wait for remaining messages to drain
|
||||
(publish-buffer/flush-publish-buffer!)
|
||||
(let [drain-start (System/currentTimeMillis)]
|
||||
(while (and (< @received @published)
|
||||
(< (- (System/currentTimeMillis) drain-start) 30000))
|
||||
(publish-buffer/flush-publish-buffer!)
|
||||
(Thread/sleep (long 500))))
|
||||
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)
|
||||
stats (collect-stats @latencies)]
|
||||
(print-table!
|
||||
(str "Sustained Throughput (" (name transport) ", rate=" (name rate)
|
||||
", duration=" duration-sec "s)")
|
||||
["Metric" "Value"]
|
||||
[["Published" (str @published)]
|
||||
["Received" (str @received)]
|
||||
["Other nodes" (str (max 0 (- @published @received)))]
|
||||
["From others" (str (max 0 (- @received @published)))]
|
||||
["Duration ms" (fmt elapsed-ms)]
|
||||
["Actual pub/sec" (fmt (/ @published (/ elapsed-ms 1000.0)))]
|
||||
["Actual recv/sec" (fmt (/ @received (/ elapsed-ms 1000.0)))]
|
||||
["P50 latency ms" (fmt (:p50 stats))]
|
||||
["P95 latency ms" (fmt (:p95 stats))]
|
||||
["P99 latency ms" (fmt (:p99 stats))]
|
||||
["Max latency ms" (fmt (:max stats))]])))
|
||||
(finally
|
||||
(doseq [ch channels]
|
||||
(when (listener/get-listener ch)
|
||||
(mq/unlisten! ch))))))))
|
||||
|
||||
;;; ---------------------------------------- Benchmark 4: Batch Listener ----------------------------------------
|
||||
|
||||
(defn bench-batch-listener!
|
||||
"Compares single-message vs batch listener processing rates.
|
||||
Uses the shared queues so multiple nodes compete.
|
||||
Options:
|
||||
:n - total messages to publish (default 200)
|
||||
:batch-sizes - vector of max-batch-messages values to test (default [1 10 50])"
|
||||
([] (bench-batch-listener! {}))
|
||||
([{:keys [n batch-sizes]
|
||||
:or {n 200 batch-sizes [1 10 50]}}]
|
||||
(ensure-started!)
|
||||
(let [results (atom [])]
|
||||
(doseq [bs batch-sizes]
|
||||
(let [received (atom 0)]
|
||||
(try
|
||||
;; Register listeners on all shared queues
|
||||
(doseq [ch shared-queues]
|
||||
(when-not (listener/get-listener ch)
|
||||
(if (= bs 1)
|
||||
(mq/listen! ch {}
|
||||
(fn [_msg]
|
||||
(swap! received inc)))
|
||||
(mq/batch-listen! ch
|
||||
(fn [msgs]
|
||||
(swap! received + (count msgs)))
|
||||
{:max-batch-messages bs}))))
|
||||
;; Publish all messages randomly to shared queues
|
||||
(dotimes [i n]
|
||||
(mq/with-queue (random-shared-queue) [q]
|
||||
(mq/put q {:seq i})))
|
||||
(publish-buffer/flush-publish-buffer!)
|
||||
;; Wait for messages to be consumed
|
||||
(let [start (System/nanoTime)
|
||||
deadline (+ (System/currentTimeMillis) 30000)]
|
||||
(while (and (< @received n)
|
||||
(< (System/currentTimeMillis) deadline))
|
||||
(Thread/sleep (long 100)))
|
||||
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)]
|
||||
(swap! results conj
|
||||
[(str bs) (str @received) (fmt elapsed-ms)
|
||||
(fmt (/ @received (/ elapsed-ms 1000.0)))])))
|
||||
(finally
|
||||
(doseq [ch shared-queues]
|
||||
(when (listener/get-listener ch)
|
||||
(mq/unlisten! ch)))))))
|
||||
(print-table!
|
||||
(str "Batch Listener Comparison (n=" n ")")
|
||||
["Max Batch" "Received" "Process ms" "Msgs/sec"]
|
||||
@results))))
|
||||
|
||||
;;; ---------------------------------------- Benchmark 5: Multiple Queues ----------------------------------------
|
||||
|
||||
(defn bench-multi-queue!
|
||||
"Measures performance with varying numbers of the shared queues.
|
||||
Tests with subsets of the 10 shared queues to show scaling.
|
||||
Options:
|
||||
:queue-counts - vector of queue counts to test (default [1 5 10])
|
||||
:msgs-per-queue - messages per queue (default 50)"
|
||||
([] (bench-multi-queue! {}))
|
||||
([{:keys [queue-counts msgs-per-queue]
|
||||
:or {queue-counts [1 5 10] msgs-per-queue 50}}]
|
||||
(ensure-started!)
|
||||
(let [results (atom [])]
|
||||
(doseq [qc queue-counts]
|
||||
(let [channels (subvec shared-queues 0 (min qc (count shared-queues)))
|
||||
total (* (count channels) msgs-per-queue)
|
||||
received (atom 0)]
|
||||
(try
|
||||
;; Register listeners
|
||||
(doseq [ch channels]
|
||||
(when-not (listener/get-listener ch)
|
||||
(mq/listen! ch {}
|
||||
(fn [_msg]
|
||||
(swap! received inc)))))
|
||||
;; Publish to the queues
|
||||
(let [start (System/nanoTime)]
|
||||
(doseq [ch channels]
|
||||
(dotimes [i msgs-per-queue]
|
||||
(mq/with-queue ch [q]
|
||||
(mq/put q {:seq i}))))
|
||||
(publish-buffer/flush-publish-buffer!)
|
||||
;; Wait for messages
|
||||
(let [deadline (+ (System/currentTimeMillis) 60000)]
|
||||
(while (and (< @received total)
|
||||
(< (System/currentTimeMillis) deadline))
|
||||
(Thread/sleep (long 100))))
|
||||
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)]
|
||||
(swap! results conj
|
||||
[(str (count channels)) (str total) (str @received) (fmt elapsed-ms)
|
||||
(fmt (/ @received (/ elapsed-ms 1000.0)))])))
|
||||
(finally
|
||||
(doseq [ch channels]
|
||||
(when (listener/get-listener ch)
|
||||
(mq/unlisten! ch)))))))
|
||||
(print-table!
|
||||
(str "Multiple Queues (msgs-per-queue=" msgs-per-queue ")")
|
||||
["Queues" "Total Msgs" "Received" "Total ms" "Msgs/sec"]
|
||||
@results))))
|
||||
|
||||
;;; ---------------------------------------- Run All ----------------------------------------
|
||||
|
||||
(defn run-all-benchmarks!
|
||||
"Runs all benchmark scenarios with default parameters."
|
||||
[]
|
||||
(println "=== MQ Performance Benchmarks ===")
|
||||
(println)
|
||||
|
||||
(println "--- 1/5: Publishing Throughput (Queue) ---")
|
||||
(bench-publish-throughput! {:n 1000 :transport :queue})
|
||||
|
||||
(println "--- 1b/5: Publishing Throughput (Topic) ---")
|
||||
(bench-publish-throughput! {:n 1000 :transport :topic})
|
||||
|
||||
(println "--- 2/5: End-to-End Latency ---")
|
||||
(doseq [rate [:low :medium :high :burst]]
|
||||
(bench-e2e-latency! {:n 50 :rate rate :transport :queue}))
|
||||
(doseq [rate [:low :medium :burst]]
|
||||
(bench-e2e-latency! {:n 50 :rate rate :transport :topic}))
|
||||
|
||||
(println "--- 3/5: Sustained Throughput ---")
|
||||
(bench-sustained-throughput! {:duration-sec 15 :rate :medium :transport :queue})
|
||||
(bench-sustained-throughput! {:duration-sec 15 :rate :medium :transport :topic})
|
||||
|
||||
(println "--- 4/5: Batch Listener Comparison ---")
|
||||
(bench-batch-listener! {:n 200})
|
||||
|
||||
(println "--- 5/5: Multiple Queues ---")
|
||||
(bench-multi-queue! {:queue-counts [1 5 10 20] :msgs-per-queue 50})
|
||||
|
||||
(println "=== All benchmarks complete ==="))
|
||||
|
||||
;;; ---------------------------------------- Multi-Node Coordination ----------------------------------------
|
||||
|
||||
(defn run-all-benchmarks-coordinated!
|
||||
"Waits for a 'go' signal on :topic/bench-coordination, then runs all benchmarks.
|
||||
Use with multiple JVM instances to start benchmarks simultaneously.
|
||||
|
||||
Usage:
|
||||
;; On each node:
|
||||
(start-mq!)
|
||||
(run-all-benchmarks-coordinated!)
|
||||
|
||||
;; Then from any node or separate REPL:
|
||||
(signal-go!)"
|
||||
[]
|
||||
(let [latch (CountDownLatch. 1)]
|
||||
(mq/listen! :topic/bench-coordination {}
|
||||
(fn [_msg]
|
||||
(.countDown latch)))
|
||||
(println "Waiting for go signal... (call (signal-go!) from any node)")
|
||||
(.await latch 300 TimeUnit/SECONDS)
|
||||
(mq/unlisten! :topic/bench-coordination)
|
||||
(println "Go signal received! Starting benchmarks...")
|
||||
(run-all-benchmarks!)))
|
||||
|
||||
(defn signal-go!
|
||||
"Sends the go signal to all waiting nodes."
|
||||
[]
|
||||
(mq/with-topic :topic/bench-coordination [t]
|
||||
(mq/put t {:go true :time (System/currentTimeMillis)}))
|
||||
(println "Go signal sent."))
|
||||
104
dev/src/dev/mq_perf_multi.sh
Executable file
104
dev/src/dev/mq_perf_multi.sh
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs multiple MQ benchmark nodes against the same database.
|
||||
#
|
||||
# Usage:
|
||||
# ./dev/src/dev/mq_perf_multi.sh start # Start 2 nodes (default)
|
||||
# ./dev/src/dev/mq_perf_multi.sh start 4 # Start 4 nodes
|
||||
# ./dev/src/dev/mq_perf_multi.sh go # Send go signal to all waiting nodes
|
||||
# ./dev/src/dev/mq_perf_multi.sh stop # Stop all running nodes
|
||||
# ./dev/src/dev/mq_perf_multi.sh logs # Tail all node logs
|
||||
#
|
||||
# Each node starts a lightweight JVM with just DB + MQ (no server),
|
||||
# waits for a coordinated go signal, then runs all benchmarks.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
LOG_DIR="target/mq-perf-logs"
|
||||
PID_DIR="target/mq-perf-pids"
|
||||
|
||||
cmd_start() {
|
||||
local num_nodes="${1:-2}"
|
||||
mkdir -p "$LOG_DIR" "$PID_DIR"
|
||||
|
||||
# Clean up stale pids
|
||||
rm -f "$PID_DIR"/*.pid
|
||||
|
||||
echo "Starting $num_nodes MQ benchmark nodes..."
|
||||
echo "Logs in $LOG_DIR/"
|
||||
echo
|
||||
|
||||
for i in $(seq 1 "$num_nodes"); do
|
||||
local log_file="$LOG_DIR/node-$i.log"
|
||||
clojure -M:dev:ee:ee-dev -e "
|
||||
(require 'dev.mq-perf)
|
||||
(dev.mq-perf/start-mq!)
|
||||
(dev.mq-perf/run-all-benchmarks-coordinated!)
|
||||
" > "$log_file" 2>&1 &
|
||||
local pid=$!
|
||||
echo "$pid" > "$PID_DIR/node-$i.pid"
|
||||
echo " Node $i started (PID $pid, log: $log_file)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "Nodes are initializing. Once ready, run:"
|
||||
echo " $0 go"
|
||||
}
|
||||
|
||||
cmd_go() {
|
||||
echo "Sending go signal..."
|
||||
clojure -M:dev:ee:ee-dev -e "
|
||||
(require 'dev.mq-perf)
|
||||
(dev.mq-perf/start-mq!)
|
||||
(dev.mq-perf/signal-go!)
|
||||
"
|
||||
echo
|
||||
echo "Benchmarks started. Watch progress with:"
|
||||
echo " $0 logs"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
if [ ! -d "$PID_DIR" ]; then
|
||||
echo "No nodes running."
|
||||
return
|
||||
fi
|
||||
|
||||
for pid_file in "$PID_DIR"/*.pid; do
|
||||
[ -f "$pid_file" ] || continue
|
||||
local pid
|
||||
pid=$(cat "$pid_file")
|
||||
local name
|
||||
name=$(basename "$pid_file" .pid)
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid"
|
||||
echo "Stopped $name (PID $pid)"
|
||||
else
|
||||
echo "$name (PID $pid) already exited"
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
if [ ! -d "$LOG_DIR" ]; then
|
||||
echo "No logs found."
|
||||
return
|
||||
fi
|
||||
tail -f "$LOG_DIR"/node-*.log
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) cmd_start "${2:-2}" ;;
|
||||
go) cmd_go ;;
|
||||
stop) cmd_stop ;;
|
||||
logs) cmd_logs ;;
|
||||
*)
|
||||
echo "Usage: $0 {start [N]|go|stop|logs}"
|
||||
echo
|
||||
echo " start [N] Start N benchmark nodes (default 2)"
|
||||
echo " go Send go signal to start benchmarks"
|
||||
echo " stop Kill all running nodes"
|
||||
echo " logs Tail all node logs"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -29,7 +29,6 @@
|
||||
[metabase.search.config :as search.config]
|
||||
[metabase.search.engine :as search.engine]
|
||||
[metabase.search.impl :as search.impl]
|
||||
[metabase.search.ingestion :as search.ingestion]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.random :as u.random]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -446,9 +445,7 @@
|
||||
:num-users num-users
|
||||
:prefix prefix})
|
||||
user-id (:user-id test-env)
|
||||
;; Wait for search index to update if using async indexing
|
||||
_ (binding [search.ingestion/*force-sync* true]
|
||||
(Thread/sleep 1000))
|
||||
_ (Thread/sleep 1000) ;; Wait for search index
|
||||
queries [;; Exact prefix match
|
||||
(str prefix "_table_00001")
|
||||
;; Partial prefix match
|
||||
|
||||
@@ -180,6 +180,8 @@ Metabase's reference documentation.
|
||||
#### Paid SSO options
|
||||
|
||||
- [JWT-based authentication](./people-and-groups/authenticating-with-jwt.md)
|
||||
- [OIDC-based authentication](./people-and-groups/authenticating-with-oidc.md)
|
||||
- [OIDC with Keycloak](./people-and-groups/oidc-keycloak.md)
|
||||
- [SAML-based authentication](./people-and-groups/authenticating-with-saml.md)
|
||||
- [SAML with Auth0](./people-and-groups/saml-auth0.md)
|
||||
- [SAML with Microsoft Entra ID](./people-and-groups/saml-azure.md)
|
||||
|
||||
68
docs/ai/metabot-slack.md
Normal file
68
docs/ai/metabot-slack.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Metabot in Slack
|
||||
summary: Chat with Metabot directly in Slack to ask questions about your data, get charts, upload CSVs, and manage subscriptions.
|
||||
---
|
||||
|
||||
# Metabot in Slack
|
||||
|
||||

|
||||
|
||||
Chat with [Metabot](./metabot.md) in Slack. Direct message Metabot for private analysis, or mention @Metabot in a channel to collaborate with your team.
|
||||
|
||||
## What Metabot can do
|
||||
|
||||
From Slack you can ask Metabot to:
|
||||
|
||||
- **Find existing content**: Search your Metabase for questions, and dashboards. Metabot links you directly to the content in your Metabase.
|
||||
- **Answer questions**: Create ad-hoc queries from natural language to answer your questions on the spot. You can click the link to save the question in Metabase.
|
||||
- **Show charts and tables**: Render static visualizations or tabular results in Slack. You can copy table results or download them as TSV.
|
||||
- **Work with CSVs**: [Upload a CSV](../databases/uploads.md) to Metabase, then ask follow-up questions about the data.
|
||||
- **Manage notifications**: Create [subscriptions](../dashboards/subscriptions.md) and [alerts](../questions/alerts.md) from within a channel. Metabot can find an answer and subscribe you to it in one go (though Metabot won't create new questions or dashboards).
|
||||
|
||||
## Set up Metabot in Slack
|
||||
|
||||
### 1. Set up Metabot
|
||||
|
||||
Make sure [Metabot is set up](./settings.md) on your Metabase.
|
||||
|
||||
### 2. Connect Slack to Metabase
|
||||
|
||||
This is the basic Slack integration that lets Metabase send alerts and subscriptions to Slack channels. Follow the setup guide in [Set up Slack](../configuring-metabase/slack.md#create-your-slack-app).
|
||||
|
||||
### 3. Enable natural language questions
|
||||
|
||||
This is the setting lets people chat with Metabot in Slack.
|
||||
|
||||
Follow the steps in [Set up Metabot in Slack](../configuring-metabase/slack.md#set-up-metabot-in-slack) to add your Slack app credentials.
|
||||
|
||||
If you already have a Slack integration from before this feature existed, your Slack app will need additional permissions for natural language questions. The UI walks you through upgrading your app's permissions. Your existing alerts and subscriptions will keep working without upgrading; the new permissions are only needed for Metabot.
|
||||
|
||||
### 4. Connect your Slack account to Metabase
|
||||
|
||||
To chat with Metabot, people will need to link their Slack account to their Metabase account. The first time you message Metabot, it kicks off an OAuth flow that connects the two accounts. This connection lets Metabot use your Metabase permissions, so Metabot will only see data you're allowed to see.
|
||||
|
||||
## Chatting with Metabot in Slack
|
||||
|
||||
- **Message Metabot directly** for private conversations with your data. No @mention needed.
|
||||
- **@Metabot in a channel** so your team can see the question and answer. Metabot only responds to messages that @mention it in channels.
|
||||
- **Add Metabot as an AI app** in Slack's sidebar for quick access.
|
||||
|
||||
### Clearing context
|
||||
|
||||
Metabot remembers the context of a thread. To clear context and start a fresh conversation, either begin a new direct message or @mention Metabot in a new thread in a channel.
|
||||
|
||||
## Notes on privacy
|
||||
|
||||
### Answers are visible to everyone in your Slack channel
|
||||
|
||||
If you ask Metabot a question in a public channel, _everyone_ in that channel can see the response. So even though Metabot respects your Metabase [permissions](../permissions/introduction.md) (Metabot can only see what you see), be thoughtful about questions that could surface sensitive data to others that may lack your permissions.
|
||||
|
||||
### Feedback forms contain your conversation history
|
||||
|
||||
When you submit feedback, the form you send may contain sensitive data from your conversation.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Metabot](./metabot.md)
|
||||
- [Metabot AI settings](./settings.md)
|
||||
- [Set up Slack](../configuring-metabase/slack.md)
|
||||
BIN
docs/ai/metabot-slack.png
Normal file
BIN
docs/ai/metabot-slack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -5,8 +5,6 @@ summary: Metabot is Metabase's AI assistant that helps you analyze data, create
|
||||
|
||||
# Metabot - Metabase's AI assistant
|
||||
|
||||
> Metabot is only available as an add-on on Metabase Cloud.
|
||||
|
||||

|
||||
|
||||
Metabot helps you analyze your data by creating charts from natural language, generating SQL queries, fixing query errors, and analyzing existing visualizations.
|
||||
@@ -26,6 +24,7 @@ Metabot can help you to:
|
||||
- [Analyze a chart](#analyze-charts-with-metabot).
|
||||
- [Fix errors in SQL code](#have-metabot-fix-sql-queries).
|
||||
- Answer questions from our documentation (as in, the literature you're reading right now).
|
||||
- [Chat with Metabot in Slack](./metabot-slack.md).
|
||||
|
||||
Like with all generative AI, you'll always need to double-check results.
|
||||
|
||||
@@ -124,6 +123,10 @@ Open it from **+ New → AI exploration**, then ask a question or pick one of th
|
||||
|
||||
When answering questions in AI exploration, Metabot searches a limited set of content instead of your entire Metabase. Admins can set a **Collection for natural language querying** to scope AI exploration to a specific collection and its subcollections. You can still refer to content outside that scope by explicitly @-mentioning it in your prompt. See [Metabot AI settings](../ai/settings.md) for more details.
|
||||
|
||||
## Metabot in Slack
|
||||
|
||||
Slack Metabot directly or @mention it in a channel to ask questions about your data. See [Metabot in Slack](./metabot-slack.md).
|
||||
|
||||
## Giving feedback on Metabot responses
|
||||
|
||||
When you hover over Metabot's responses, you'll see options to give feedback.
|
||||
|
||||
@@ -21,3 +21,7 @@ Use your own API key to generate SQL from natural language on self-hosted Metaba
|
||||
## [Agent API](./agent-api.md)
|
||||
|
||||
A REST API for building headless, agentic BI applications on top of Metabase's semantic layer.
|
||||
|
||||
## [Metabot in Slack](./metabot-slack.md)
|
||||
|
||||
Chat with Metabot directly in Slack — ask questions, get charts, and manage subscriptions without leaving Slack.
|
||||
|
||||
6443
docs/api.json
6443
docs/api.json
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ redirect_from:
|
||||
|
||||
# Set up Slack
|
||||
|
||||
_Admin > Settings > Notification channels > Slack_
|
||||
_Admin > Settings > Slack_
|
||||
|
||||
If you want to have your [dashboard subscriptions](../dashboards/subscriptions.md) or [alerts](../questions/alerts.md) sent to Slack channels (or people on Slack), you need to first integrate your Metabase with Slack.
|
||||
|
||||
@@ -16,7 +16,7 @@ Both admins and people with [settings access](../permissions/application.md#sett
|
||||
|
||||
For Metabase to post to your Slack channels, you’ll need to create a Slack App and make it available to Metabase.
|
||||
|
||||
1. In Metabase, go to **Admin > Settings > Notification channels**.
|
||||
1. In Metabase, go to **Admin > Settings > Slack**.
|
||||
2. Click **Connect to Slack**.
|
||||
3. Click **Create a Slack App**. Metabase will open a new tab with the Slack website.
|
||||
4. On the Slack website, click **Create an App**.
|
||||
@@ -49,7 +49,7 @@ For Metabase to post to your Slack channels, you’ll need to create a Slack App
|
||||
- groups:read
|
||||
```
|
||||
|
||||
The manifest just take cares of some settings for your app and helps speed things along.
|
||||
The manifest just takes care of some settings for your app and helps speed things along.
|
||||
|
||||
7. Click the **Next** button. Then hit **Create** to set up your Slack app.
|
||||
|
||||
@@ -63,7 +63,7 @@ For Metabase to post to your Slack channels, you’ll need to create a Slack App
|
||||
|
||||
Once you [created the app](#create-your-slack-app) and [installed it in your workspace](#install-your-app-to-your-workspace), you tell Metabase to use it by giving Metabase the OAuth token for the app.
|
||||
|
||||
To obtain the OAuth token for the Slack aopp:
|
||||
To obtain the OAuth token for the Slack app:
|
||||
|
||||
1. Go to the Slack site page for your Slack app.
|
||||
2. On the Slack page for your app, go to **OAuth & Permissions** in the left sidebar.
|
||||
@@ -71,12 +71,20 @@ To obtain the OAuth token for the Slack aopp:
|
||||
|
||||
To set up the app in Metabase:
|
||||
|
||||
1. In Metabase, go to **Admin > Settings > Notification channels > Slack**. Click **Connect to Slack**.
|
||||
1. In Metabase, go to **Admin > Settings > Slack**. Click **Connect to Slack**.
|
||||
2. Paste the token into **Slack bot user OAuth token**.
|
||||
3. **Save changes** in Metabase.
|
||||
|
||||
Metabase will automatically run a quick test to check that the API token is working properly. If something goes wrong, it'll give you an error message.
|
||||
|
||||
## Set your app icon
|
||||
|
||||
To give your Slack app the Metabot avatar:
|
||||
|
||||
1. In Metabase, go to **Admin > Settings > Slack** and click **Download App Icon**.
|
||||
2. On the Slack site, go to your app's **Basic Information** page.
|
||||
3. Under **Display Information**, upload the icon you downloaded.
|
||||
|
||||
## Sending alerts and subscriptions to private Slack channels
|
||||
|
||||
In order to send [subscriptions](../dashboards/subscriptions.md) and [alerts](../questions/alerts.md) to private Slack channels, you must first add the Metabase app to the private channel.
|
||||
@@ -89,7 +97,7 @@ To add your app to a private Slack channel:
|
||||
|
||||
Once you added the app to a private channel, you should see the channel in the list of channels when setting up a subscription or alerts within 10 mins (it can take a little time for Metabase to see all the channels the app has been invited to).
|
||||
|
||||
In order for metabase to see private channels, the app must have the `groups:read` OAuth scope. Although this scope should be granted when setting up the app through Metabase, older installations might not have this scope.
|
||||
In order for Metabase to see private channels, the app must have the `groups:read` OAuth scope. Although this scope should be granted when setting up the app through Metabase, older installations might not have this scope.
|
||||
|
||||
To check or edit your OAuth settings:
|
||||
|
||||
@@ -99,10 +107,40 @@ To check or edit your OAuth settings:
|
||||
4. Under **Scopes**, add the `groups:read` scope if it's not added already.
|
||||
5. Reinstall the app by clicking the **Reinstall** button under **OAuth Tokens**.
|
||||
|
||||
## Natural language questions in Slack
|
||||
|
||||
> Natural language questions in Slack require [Metabot](../ai/metabot.md).
|
||||
|
||||

|
||||
|
||||
Once you've connected Slack, you can let people chat with [Metabot](../ai/metabot.md) directly in Slack channels. For more on what Metabot can do in Slack, see [Metabot in Slack](../ai/metabot-slack.md).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Slack connected to Metabase](#create-your-slack-app).
|
||||
- [Metabot set up](../ai/settings.md) on your Cloud instance. If Metabot isn't set up yet, you'll see a notice to **Go to Metabot connection settings** first.
|
||||
- [Encryption at rest](../databases/encrypting-details-at-rest.md) enabled.
|
||||
|
||||
### Set up Metabot in Slack
|
||||
|
||||
1. Go to **Admin > Settings > Slack**.
|
||||
2. Under **Natural language questions in Slack**, enter the credentials from your Slack app's **Basic Information** page (under **App Credentials**):
|
||||
- **Client ID**
|
||||
- **Client Secret** (click **Show** to reveal)
|
||||
- **Signing Secret** (click **Show** to reveal)
|
||||
3. Click **Save changes**.
|
||||
4. If Metabase detects missing OAuth scopes, follow the prompts to update your Slack app's permissions and reinstall the app.
|
||||
5. Toggle **Let people chat with Metabot** to enable or disable Metabot in Slack.
|
||||
|
||||
To remove the Metabot Slack configuration, click **Remove** at the bottom of the section.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Alerts](../questions/alerts.md)
|
||||
- [Dashboard subscriptions](../dashboards/subscriptions.md)
|
||||
- [Metabot](../ai/metabot.md)
|
||||
- [Metabot in Slack](../ai/metabot-slack.md)
|
||||
- [Metabot AI settings](../ai/settings.md)
|
||||
- [Notification permissions](../permissions/notifications.md)
|
||||
- [Setting up email](./email.md)
|
||||
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
|
||||
|
||||
@@ -22,7 +22,7 @@ Set up email for [Alerts](../questions/alerts.md) and [Dashboard subscriptions](
|
||||
|
||||
Set up Slack for Alerts and Dashboard subscriptions.
|
||||
|
||||
### [Webhooks](./webhooks.md)
|
||||
## [Webhooks](./webhooks.md)
|
||||
|
||||
Set up webhooks as notification channels for alerts.
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@ For now, webhooks are only available for [alerts](../questions/alerts.md); you c
|
||||
|
||||
## Create a webhook
|
||||
|
||||
_Admin > Settings > Notification channels_
|
||||
_Admin > Settings > Webhooks_
|
||||
|
||||
Before people can send alerts to a webhook, an admin or someone with [settings access](../permissions/application.md#settings-access) will need to create a webhook to target.
|
||||
|
||||
To create a webhook:
|
||||
|
||||
1. Click the **grid** icon in top right corner and select **Admin**.
|
||||
2. In the Admin console, go **Settings > Notification channels**.
|
||||
3. In **Webhooks for alerts**, click **Add a webhoor**.
|
||||
2. In the Admin console, go **Settings > Webhooks**.
|
||||
3. In **Webhooks for alerts**, click **Add a webhook**.
|
||||
4. Metabase will show you a short form to fill out:
|
||||
|
||||
- **Webhook URL**. Where you want Metabase to send the results of an alert. Must be a valid URL.
|
||||
|
||||
169
docs/people-and-groups/authenticating-with-oidc.md
Normal file
169
docs/people-and-groups/authenticating-with-oidc.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: OIDC-based authentication
|
||||
summary: Set up OIDC single sign-on in Metabase.
|
||||
---
|
||||
|
||||
# OIDC-based authentication
|
||||
|
||||
{% include plans-blockquote.html feature="OIDC authentication" %}
|
||||
|
||||
By integrating your OpenID Connect (OIDC) provider with Metabase, you can:
|
||||
|
||||
- [Provision a Metabase account](#user-provisioning) when someone logs in via your identity provider (IdP).
|
||||
- Let people access Metabase without re-authenticating.
|
||||
- Automatically pass user attributes (name, email) from your IdP to Metabase.
|
||||
- [Synchronize group membership](#synchronize-group-membership) so that people are automatically assigned to Metabase groups based on their IdP groups.
|
||||
|
||||
## Self-hosted Metabases must set an encryption key
|
||||
|
||||
> Metabase Cloud encrypts credentials by default, so this section only applies to self-hosted Metabases.
|
||||
|
||||
[`MB_ENCRYPTION_SECRET_KEY`](../configuring-metabase/environment-variables.md#mb_encryption_secret_key) must be set before enabling OIDC. Metabase uses this key to encrypt the OIDC state cookie. Without it, Metabase will return a 500 error when a person tries to log in via OIDC.
|
||||
|
||||
Set this key in your environment before starting Metabase:
|
||||
|
||||
```
|
||||
MB_ENCRYPTION_SECRET_KEY=your-secret-key-at-least-16-characters
|
||||
```
|
||||
|
||||
Once you set an encryption key, don't remove it. Metabase encrypts values in the database with this key and will refuse to start without it.
|
||||
|
||||
For more, see [Encrypting your database connection](../databases/encrypting-details-at-rest.md).
|
||||
|
||||
## Set up OIDC in Metabase
|
||||
|
||||
_Admin > Settings > Authentication > OIDC_
|
||||
|
||||
You can also configure OIDC with [environment variables](#environment-variables).
|
||||
|
||||
You'll need to enter the following:
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Key** | A unique identifier for this provider. Lowercase letters, numbers, and hyphens only. Can't be changed after creation. Use a name that describes the provider (e.g., `keycloak`), not the application. Metabase constructs the redirect URI as `{site-url}/auth/sso/{key}/callback`. Configure this exact URI in your IdP, so choose the key _before_ configuring your IdP for Metabase. The full redirect URI is shown in the admin UI after you enter the key. |
|
||||
| **Login prompt** | The button label on Metabase's sign-in screen. You can put something like "Sign in with Keycloak", or "Ye who wish to enter Metabase". |
|
||||
| **Issuer URI** | Your IdP's issuer URI. Metabase fetches the discovery document from `{issuer-uri}/.well-known/openid-configuration`. This request is made from Metabase's server, not from the browser. In containerized deployments (like Docker), the URI must be reachable from Metabase's container, not just from your browser. |
|
||||
| **Client ID** | The Client ID assigned for Metabase in your OIDC provider. |
|
||||
| **Client Secret** | The client secret from your OIDC provider. When editing an existing configuration, leave this blank to keep the current value. |
|
||||
|
||||
### Optional settings
|
||||
|
||||
**Scopes**: The OIDC scopes to request. In the admin UI, enter scopes as a comma-separated string (e.g., `openid, email, profile`). Defaults to `openid, email, profile`. Most providers don't require changes here. If you're setting this with the `MB_OIDC_PROVIDERS` environment variable JSON, use an array (e.g., `["openid", "email", "profile"]`).
|
||||
|
||||
### Attribute mapping
|
||||
|
||||
Metabase maps OIDC claims to account fields. The defaults work for most providers:
|
||||
|
||||
| Metabase field | Default claim |
|
||||
| -------------- | ------------- |
|
||||
| Email | `email` |
|
||||
| First name | `given_name` |
|
||||
| Last name | `family_name` |
|
||||
|
||||
If your IdP uses different claim names, update these fields to match.
|
||||
|
||||
## Synchronize group membership
|
||||
|
||||
You can configure Metabase to automatically sync group memberships from your IdP, so that people are added to (or removed from) Metabase groups based on their IdP groups.
|
||||
|
||||
OIDC doesn't define a standard groups claim. You'll need to configure your IdP to include a groups claim in the ID token. Some IdPs (like Azure AD/Entra ID) require a specific `groups` scope or API permission to include group claims — check your provider's docs. Keycloak includes groups via a client scope mapper without needing an extra scope. For a Keycloak example, see [Sync groups from Keycloak to Metabase](./oidc-keycloak.md#sync-groups-from-keycloak-to-metabase).
|
||||
|
||||
### Configure group sync in Metabase
|
||||
|
||||
> If your provider is configured via the `MB_OIDC_PROVIDERS` environment variable, group sync settings are read-only in the admin UI. Manage group mappings in the environment variable instead. See [Environment variables](#environment-variables).
|
||||
|
||||
1. Go to **Admin** > **Settings** > **Authentication** > **OIDC** and select your provider.
|
||||
2. Turn on **Synchronize Group Memberships**. You can click **About mappings** to learn more about how group mappings work.
|
||||
3. Click **New mapping** and enter the group name exactly as your IdP sends it (case-sensitive).
|
||||
4. Select one or more Metabase groups to map it to.
|
||||
5. Repeat for each group you want to map.
|
||||
6. Set the **Group attribute name** to the claim name your IdP uses for groups (defaults to `groups`). This must match the claim name in the ID token exactly.
|
||||
7. Click **Save**.
|
||||
|
||||
### How group sync works
|
||||
|
||||
- Metabase updates group memberships on each login based on the groups claim in the ID token.
|
||||
- If a person's groups claim no longer includes a mapped group, Metabase removes them from the corresponding Metabase group.
|
||||
- Only mapped groups are affected. Memberships in unmapped Metabase groups (including Admin) aren't changed.
|
||||
- If the groups claim is missing from the token entirely, Metabase leaves existing group memberships unchanged.
|
||||
|
||||
## Check connection
|
||||
|
||||
After entering your OIDC provider settings in **Admin** > **Settings** > **Authentication** > **OIDC**, click **Check connection** to verify that Metabase can reach your provider. The check verifies two things: 1) that Metabase can fetch the discovery document from your Issuer URI, and 2) that the client credentials are valid.
|
||||
|
||||
If the check reports "OIDC discovery succeeded, but credentials could not be verified", this could mean you need to configure the client to support the grant type Metabase uses for testing. In a Keycloak client, for example, you should turn on **Service accounts roles** and **Client authentication**.
|
||||
|
||||
## User provisioning
|
||||
|
||||
By default, Metabase creates accounts for people who authenticate successfully via OIDC but don't yet have a Metabase account. Metabase uses the email claim to identify and create accounts, so the `email` scope must be included in what your IdP returns.
|
||||
|
||||
You can turn auto-provisioning off under **Admin settings** > **Authentication** > **OIDC** > **User provisioning**. If you've set up [User provisioning with SCIM](./user-provisioning.md), turn off automatic account creation to avoid conflicts.
|
||||
|
||||
### How auto-provisioning works
|
||||
|
||||
If auto-provisioning is on, when someone logs in via OIDC, Metabase will look up the account by email (case-insensitive). If an account with that email already exists, Metabase links the OIDC identity to that existing account. On each OIDC login, Metabase updates the account's name from the IdP's claims. Otherwise, Metabase will automatically create a new account.
|
||||
|
||||
If auto-provisioning is off, and no matching account exists, the person gets an error, and can't log in.
|
||||
|
||||
## Environment variables
|
||||
|
||||
You can also configure OIDC via environment variables instead of the admin UI. Settings configured via environment variables are read-only in the admin UI.
|
||||
|
||||
- [`MB_OIDC_ALLOWED_NETWORKS`](../configuring-metabase/environment-variables.md#mb_oidc_allowed_networks): controls which networks OIDC requests are allowed to reach. Possible values: `allow-all` (default, no restrictions), `allow-private` (allows external and private networks, but blocks localhost and loopback), or `external-only` (blocks requests to private networks).
|
||||
- [`MB_OIDC_USER_PROVISIONING_ENABLED`](../configuring-metabase/environment-variables.md#mb_oidc_user_provisioning_enabled): toggle auto-provisioning (`true` by default).
|
||||
- [`MB_OIDC_PROVIDERS`](../configuring-metabase/environment-variables.md#mb_oidc_providers): JSON string containing the full provider configuration. The value is an array of provider objects. Here's an example:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"key": "keycloak",
|
||||
"login-prompt": "Sign in with Keycloak",
|
||||
"issuer-uri": "http://keycloak:8080/realms/metabase",
|
||||
"client-id": "metabase-client",
|
||||
"client-secret": "metabase-client-secret",
|
||||
"scopes": ["openid", "email", "profile"],
|
||||
"enabled": true,
|
||||
"attribute-map": {
|
||||
"email": "email",
|
||||
"first_name": "given_name",
|
||||
"last_name": "family_name"
|
||||
},
|
||||
"group-sync": {
|
||||
"enabled": true,
|
||||
"group-attribute": "groups",
|
||||
"group-mappings": {
|
||||
"engineering": [5, 6],
|
||||
"data-team": [3]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Required | Default |
|
||||
| ------------------------------ | -------- | -------------------------------- |
|
||||
| **key** | Yes | |
|
||||
| **login-prompt** | Yes | |
|
||||
| **issuer-uri** | Yes | |
|
||||
| **client-id** | Yes | |
|
||||
| **client-secret** | Yes | |
|
||||
| **enabled** | No | `true` |
|
||||
| **scopes** | No | `["openid", "email", "profile"]` |
|
||||
| **attribute-map** | No | See attribute map above |
|
||||
| **group-sync.enabled** | No | `false` |
|
||||
| **group-sync.group-attribute** | No | `"groups"` |
|
||||
| **group-sync.group-mappings** | No | `{}` |
|
||||
|
||||
The `group-sync` fields are nested inside each provider object. In `group-mappings`, keys are IdP group names and values are arrays of Metabase group IDs. You can find group IDs under **Admin** > **People** > **Groups**, or via the `GET /api/permissions/group` API endpoint.
|
||||
|
||||
See [Environment variables](../configuring-metabase/environment-variables.md).
|
||||
|
||||
## Disable password logins
|
||||
|
||||
> **Avoid locking yourself out.** Disabling password logins applies to all accounts, including your Metabase admin account. Before doing this, verify that you can log in with SSO.
|
||||
|
||||
To require SSO for all logins, go to **Admin settings** > **Authentication** and turn off **Enable Password Authentication**.
|
||||
|
||||
## OIDC example provider guide
|
||||
|
||||
For a step-by-step walkthrough, see [Keycloak](./oidc-keycloak.md).
|
||||
111
docs/people-and-groups/oidc-keycloak.md
Normal file
111
docs/people-and-groups/oidc-keycloak.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: OIDC with Keycloak
|
||||
summary: Step-by-step guide to setting up Keycloak as an OIDC identity provider for Metabase.
|
||||
---
|
||||
|
||||
# OIDC with Keycloak
|
||||
|
||||
{% include plans-blockquote.html feature="OIDC authentication" %}
|
||||
|
||||
Keycloak is an open-source identity provider that supports [OpenID Connect (OIDC) authentication](./authenticating-with-oidc.md) out of the box.
|
||||
|
||||
1. [Set up OIDC in Keycloak](#set-up-oidc-in-keycloak) (the identity provider).
|
||||
2. [Set up OIDC in Metabase](./authenticating-with-oidc.md#set-up-oidc-in-metabase) (the service provider).
|
||||
3. [Sync groups from Keycloak to Metabase](#sync-groups-from-keycloak-to-metabase) (optional).
|
||||
|
||||
## Set up OIDC in Keycloak
|
||||
|
||||
Set up a realm, user, and client in Keycloak for Metabase.
|
||||
|
||||
1. In Keycloak, sign in as an administrator and go to the admin console.
|
||||
|
||||
2. Create a new [realm](https://www.keycloak.org/docs/latest/server_admin/index.html#core-concepts-and-terms) for Metabase.
|
||||
|
||||
3. Create a user in that realm from **Users** > **Add user**.
|
||||
|
||||
1. Fill in **Email**, **First name**, and **Last name**. The email field is required because Metabase uses this email as the account identifier. Metabase maps these fields to user properties.
|
||||
2. Turn on **Email verified**. Without verification, Keycloak won't allow the person to log in.
|
||||
3. Make sure **Required user actions** is empty. If any actions are listed, remove them. Keycloak blocks login until required actions are completed.
|
||||
4. Click **Create**, then go to the user's **Credentials** (on the top menu, not the side menu).
|
||||
5. Click **Set password**, enter a password, and turn off the **Temporary** toggle.
|
||||
|
||||
4. Create a new client from **Clients** > **Create client**.
|
||||
|
||||
1. Set **Client type** to `OpenID Connect`.
|
||||
2. Enter a **Client ID** (e.g., `metabase-client`).
|
||||
3. Click **Next**.
|
||||
|
||||
5. On the **Capability config** page:
|
||||
|
||||
1. Turn on **Client authentication** (this makes the client confidential and enables the client secret).
|
||||
2. Turn on **Service accounts roles**. Metabase's **Check connection** test authenticates using the OAuth 2.0 client credentials grant, which requires service accounts. If service accounts aren't allowed, the test returns "the identity provider does not support the grant type used for testing".
|
||||
3. Click **Next**.
|
||||
|
||||
> If you keep getting the "grant type" error even with **Service accounts roles** on, double-check the client secret in Metabase. You can check the Keycloak event log (**Events** in the Keycloak sidebar) for a `CLIENT_LOGIN_ERROR` entry to confirm (you'll need to have turned on [event logging](https://www.keycloak.org/docs/latest/server_admin/index.html#configuring-auditing-to-track-events)).
|
||||
|
||||
6. On the **Login settings** page:
|
||||
|
||||
1. Set **Valid redirect URIs** to `{metabase-url}/auth/sso/{key}/callback`, where `{key}` is the key you'll use when configuring OIDC in Metabase (like `keycloak`).
|
||||
2. Set **Web origins** to your Metabase URL (like `https://metabase.your-company.com`).
|
||||
3. Click **Save**.
|
||||
|
||||
7. Go to the client's **Credentials** tab and copy the client secret.
|
||||
|
||||
## Set up OIDC in Metabase
|
||||
|
||||
If you're self-hosting Metabase, make sure you've set `MB_ENCRYPTION_SECRET_KEY` before enabling OIDC. See [OIDC-based authentication](./authenticating-with-oidc.md#self-hosted-metabases-must-set-an-encryption-key) for details.
|
||||
|
||||
Go to **Admin settings** > **Authentication** > **OIDC** and enter:
|
||||
|
||||
| Field | Value |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| **Key** | e.g., `keycloak` |
|
||||
| **Login prompt** | e.g., `Sign in with Keycloak` |
|
||||
| **Issuer URI** | `{keycloak-url}/realms/{realm-name}` |
|
||||
| **Client ID** | The Client ID from step 4 |
|
||||
| **Client Secret** | The client secret from the client's **Credentials** tab |
|
||||
|
||||
See [OIDC-based authentication](./authenticating-with-oidc.md#set-up-oidc-in-metabase) for more on the Issuer URI (including container/Docker networking considerations).
|
||||
|
||||
Click **Check connection**, then **Save and enable**.
|
||||
|
||||
If the connection check fails with "the identity provider does not support the grant type used for testing", check:
|
||||
|
||||
- **Service accounts are enabled**: Make sure **Service accounts roles** is on in Keycloak's **Capability config** settings for this client. (Requires **Client authentication** to be on first.)
|
||||
- **You have the correct client secret**: In Keycloak, go to **Clients** > your client > **Credentials** tab, click **Regenerate**. Then re-enter the new secret in Metabase.
|
||||
|
||||
Check the [Keycloak event log](https://www.keycloak.org/docs/latest/server_admin/index.html#auditing-user-events) (**Events** in the Keycloak sidebar) for a `CLIENT_LOGIN_ERROR` entry to narrow down the issue. (You may need to enable event logging first under **Realm settings** > **Events** > **Event listeners**.)
|
||||
|
||||
- `unauthorized_client` means service accounts aren't enabled.
|
||||
- `invalid_client` means the secret is wrong.
|
||||
|
||||
## Map attributes from Keycloak to Metabase
|
||||
|
||||
Keycloak's default OIDC claims (`email`, `given_name`, `family_name`) match Metabase's defaults, so no extra configuration is needed. See [Attribute mapping](./authenticating-with-oidc.md#attribute-mapping).
|
||||
|
||||
## Sync groups from Keycloak to Metabase
|
||||
|
||||
You can configure Keycloak to include a groups claim in the OIDC token, then set up Metabase to sync group memberships based on that claim.
|
||||
|
||||
### Add a groups claim in Keycloak
|
||||
|
||||
1. In the Keycloak admin console, go to **Clients** and select your Metabase client (e.g., `metabase-client`).
|
||||
2. Go to the **Client scopes** tab and click the dedicated scope (e.g., `metabase-client-dedicated`).
|
||||
3. Click **Add mapper** > **By configuration**.
|
||||
4. Select **Group Membership** from the list.
|
||||
5. Configure the mapper:
|
||||
- **Name**: `groups` (or any descriptive name).
|
||||
- **Token Claim Name**: `groups`. This is the claim name Metabase will look for. It must match the **Group attribute name** you set in Metabase.
|
||||
- **Full group path**: When on, group names include the full path (e.g., `/engineering`). The group name in your Metabase mapping must exactly match the value in the token.
|
||||
- **Add to ID token**: On.
|
||||
6. Click **Save**.
|
||||
|
||||
### Configure group sync in Metabase
|
||||
|
||||
Once Keycloak includes the groups claim in the token, set up the mapping in Metabase. See [Configure group sync in Metabase](./authenticating-with-oidc.md#configure-group-sync-in-metabase) for the steps.
|
||||
|
||||
## Verify the setup
|
||||
|
||||
1. Open an incognito window and go to your Metabase URL. The sign-in screen should show a button with your login prompt text.
|
||||
2. Click the button. You should be redirected to Keycloak's login page.
|
||||
3. Log in with the user you created. Metabase should create the account and log you in. If you've configured group sync, check that they've been added to the correct group.
|
||||
@@ -44,6 +44,8 @@ With [Pro and Enterprise plans](https://www.metabase.com/pricing/), you have mor
|
||||
- LDAP advanced features
|
||||
- [Group membership filter](./ldap.md#ldap-group-membership-filter)
|
||||
- [Syncing user attributes](./ldap.md#syncing-user-attributes-with-ldap)
|
||||
- [OIDC][oidc]
|
||||
- [Keycloak][oidc-keycloak]
|
||||
- [SAML][saml]
|
||||
- [Auth0][saml-auth0]
|
||||
- [Microsoft Entra ID][azure-ad]
|
||||
@@ -59,6 +61,8 @@ Create keys to authenticate API calls.
|
||||
|
||||
Metabase supports user provisioning via the SCIM protocol.
|
||||
|
||||
[oidc]: ./authenticating-with-oidc.md
|
||||
[oidc-keycloak]: ./oidc-keycloak.md
|
||||
[azure-ad]: ./saml-azure.md
|
||||
[google-sign-in]: ./google-and-ldap.md#enabling-google-sign-in
|
||||
[jwt]: ./authenticating-with-jwt.md
|
||||
|
||||
@@ -48,9 +48,7 @@ Copy the token and save the token somewhere safe. For security, Metabase can't s
|
||||
|
||||
Once you've enabled SCIM in Metabase and gotten your SCIM endpoint URL and SCIM token, follow the docs for [Setting up SCIM in Okta](https://help.okta.com/en-us/content/topics/apps/apps_app_integration_wizard_scim.htm?cshid=ext_Apps_App_Integration_Wizard-scim).
|
||||
|
||||
With Okta, Metabase supports user and group provisioning; groups are created and populated in Metabase.
|
||||
|
||||
NOTE: the authentication mode you need to set is "HTTP Header"
|
||||
With Okta, Metabase supports user and group provisioning; groups are created and populated in Metabase. The authentication mode you need to set is "HTTP Header".
|
||||
|
||||
## SCIM with Microsoft Entra ID
|
||||
|
||||
@@ -63,3 +61,4 @@ With Microsoft Entra ID, Metabase only supports user provisioning (groups aren't
|
||||
- [Authenticating with SAML](./authenticating-with-saml.md)
|
||||
- [SAML with Okta](./saml-okta.md)
|
||||
- [SAML with Microsoft Entra ID](./saml-azure.md)
|
||||
- [OIDC-based authentication](./authenticating-with-oidc.md)
|
||||
|
||||
99
e2e/cross-version/57/00-setup.cy.spec.ts
Normal file
99
e2e/cross-version/57/00-setup.cy.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Cross-version test: Instance setup
|
||||
*
|
||||
* This must be the first test to run. It completes the Metabase setup wizard
|
||||
* in the source phase, and verifies the admin can authenticate in both phases.
|
||||
*
|
||||
* Uses credentials from e2e/support/cypress_data.js so cy.signInAsAdmin() works.
|
||||
*/
|
||||
|
||||
import { USERS } from "e2e/support/cypress_data";
|
||||
|
||||
const { email: ADMIN_EMAIL, password: ADMIN_PASSWORD } = USERS.admin;
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version: Instance setup", () => {
|
||||
it("setup: completes the setup wizard", { tags: ["@source"] }, () => {
|
||||
H.restoreCrossVersionDev("blank");
|
||||
cy.visit("/");
|
||||
|
||||
cy.request("GET", "/api/session/properties").then(({ body }) => {
|
||||
const setupToken = body["setup-token"];
|
||||
|
||||
cy.log("Setup token must exist on a fresh instance");
|
||||
expect(setupToken).to.be.a("string");
|
||||
|
||||
cy.request("POST", "/api/setup", {
|
||||
token: setupToken,
|
||||
user: {
|
||||
email: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
first_name: "Admin",
|
||||
last_name: "CrossVersion",
|
||||
},
|
||||
prefs: {
|
||||
site_name: "CrossVersion Test",
|
||||
site_locale: "en",
|
||||
allow_tracking: false,
|
||||
},
|
||||
}).then((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
});
|
||||
|
||||
cy.request("GET", "/api/user/current").then((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
expect(response.body.email).to.eq(ADMIN_EMAIL);
|
||||
|
||||
cy.log("Dismiss `it's ok to play around` modal for admin");
|
||||
cy.request("PUT", `/api/user/${response.body.id}/modal/qbnewb`);
|
||||
});
|
||||
|
||||
H.withSampleDatabase(({ ORDERS, PRODUCTS, REVIEWS }) => {
|
||||
cy.log("Remap display values to use foreign key as Product Title");
|
||||
cy.request("POST", `/api/field/${ORDERS.PRODUCT_ID}/dimension`, {
|
||||
name: "Product ID",
|
||||
type: "external",
|
||||
human_readable_field_id: PRODUCTS.TITLE,
|
||||
});
|
||||
|
||||
cy.log("Remap rating to use custom values");
|
||||
cy.request("POST", `/api/field/${REVIEWS.RATING}/dimension`, {
|
||||
type: "internal",
|
||||
name: "Rating",
|
||||
});
|
||||
|
||||
cy.request("POST", `/api/field/${REVIEWS.RATING}/values`, {
|
||||
values: [
|
||||
[1, "Awful"],
|
||||
[2, "Unpleasant"],
|
||||
[3, "Meh"],
|
||||
[4, "Enjoyable"],
|
||||
[5, "Perfecto"],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("verify: admin can authenticate", { tags: ["@target"] }, () => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/browse/databases");
|
||||
cy.findByTestId("database-browser").contains("Sample Database").click();
|
||||
|
||||
cy.log("Verify REVIEWS.Rating values display custom remapping");
|
||||
cy.findAllByRole("link").filter(":contains(Reviews)").click();
|
||||
cy.findAllByRole("gridcell")
|
||||
.should("contain", "Enjoyable")
|
||||
.and("contain", "Perfecto");
|
||||
|
||||
cy.go("back");
|
||||
cy.log("Verify ORDERS.ProductId FK is remapped to Product Title");
|
||||
cy.findAllByRole("link").filter(":contains(Orders)").click();
|
||||
cy.findAllByRole("gridcell")
|
||||
.should("contain", "Fantastic Wool Shirt")
|
||||
.and("contain", "Small Marble Hat");
|
||||
H.snapshotCrossVersionDev("00-complete");
|
||||
});
|
||||
});
|
||||
73
e2e/cross-version/57/01-pivot.cy.spec.ts
Normal file
73
e2e/cross-version/57/01-pivot.cy.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Q1_NAME } from "../constants";
|
||||
|
||||
import * as X from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version questions - pivot", () => {
|
||||
it("setup: creates a pivot table", { tags: ["@source"] }, () => {
|
||||
H.restoreCrossVersionDev("00-complete");
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.log("-- Create a pivot table --");
|
||||
X.startQuestionFromTable("People");
|
||||
|
||||
cy.log(
|
||||
"-- Add filter: Narrow down the states that start with K (only two) --",
|
||||
);
|
||||
H.getNotebookStep("filter")
|
||||
.findByText("Add filters to narrow your answer")
|
||||
.click();
|
||||
X.selectFromPopover("State");
|
||||
cy.findByLabelText("Filter operator").click();
|
||||
X.selectFromPopover("Starts with");
|
||||
cy.findByLabelText("Filter value").type("K").blur();
|
||||
cy.button("Add filter").click();
|
||||
|
||||
cy.log("-- Add aggregation --");
|
||||
H.addSummaryField({ metric: "Count of rows" });
|
||||
|
||||
cy.log("-- Add breakouts --");
|
||||
H.addSummaryGroupingField({ field: "State" });
|
||||
H.addSummaryGroupingField({ field: "Source" });
|
||||
H.addSummaryGroupingField({ field: "Created At" });
|
||||
|
||||
H.visualize();
|
||||
|
||||
cy.log("-- Render as pivot table --");
|
||||
cy.intercept("POST", "/api/dataset/pivot").as("pivot");
|
||||
H.openVizTypeSidebar();
|
||||
H.vizTypeSidebar().within(() => {
|
||||
cy.findAllByRole("option").filter(":contains(Pivot Table)").click();
|
||||
cy.wait("@pivot");
|
||||
cy.button("Done").click();
|
||||
});
|
||||
|
||||
H.vizTypeSidebar().should("not.exist");
|
||||
X.assertRowCount("155");
|
||||
|
||||
X.saveQuestion(Q1_NAME);
|
||||
});
|
||||
|
||||
it("verify: pivot table is preserved", { tags: ["@target"] }, () => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/collection/root");
|
||||
|
||||
cy.log(`-- ${Q1_NAME}: Assert that the pivot table viz is preserved --`);
|
||||
cy.findAllByTestId("collection-entry-name")
|
||||
.filter(`:contains(${Q1_NAME})`)
|
||||
.click();
|
||||
|
||||
X.assertRowCount("155");
|
||||
|
||||
cy.findAllByTestId("pivot-table-cell")
|
||||
.should("contain", "State")
|
||||
.should("contain", "Created At: Month")
|
||||
.and("contain", "Organic")
|
||||
.and("contain", "Row totals")
|
||||
.and("contain", "KS");
|
||||
|
||||
H.snapshotCrossVersionDev("01-complete");
|
||||
});
|
||||
});
|
||||
75
e2e/cross-version/57/02-joins.cy.spec.ts
Normal file
75
e2e/cross-version/57/02-joins.cy.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Q2_NAME } from "../constants";
|
||||
|
||||
import * as X from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version questions - joins", () => {
|
||||
it("setup: creates a joined question", { tags: ["@source"] }, () => {
|
||||
H.restoreCrossVersionDev("01-complete");
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.log("-- Create a joined question --");
|
||||
|
||||
cy.visit("/");
|
||||
H.newButton("Question").click();
|
||||
|
||||
X.joinTables("Orders", "Products");
|
||||
|
||||
cy.log("-- Filter on the joined table --");
|
||||
H.getNotebookStep("filter")
|
||||
.findByText("Add filters to narrow your answer")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("heading", { name: "Products" }).click();
|
||||
cy.findByLabelText("Price").click();
|
||||
});
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByLabelText("Filter operator").should("have.text", "Between");
|
||||
cy.findByPlaceholderText("Min").type("0");
|
||||
cy.findByPlaceholderText("Min").type("50").blur();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
cy.log("-- Add aggregation --");
|
||||
H.addSummaryField({ metric: "Average of ..." });
|
||||
X.selectFromPopover("Discount");
|
||||
|
||||
cy.log("-- Add breakouts --");
|
||||
H.addSummaryGroupingField({ table: "Products", field: "Category" });
|
||||
|
||||
cy.log("-- Sort the columns --");
|
||||
cy.findByLabelText("Sort").click();
|
||||
X.selectFromPopover("Average of Discount");
|
||||
|
||||
H.visualize();
|
||||
X.assertRowCount("4");
|
||||
|
||||
X.saveQuestion(Q2_NAME);
|
||||
});
|
||||
|
||||
it("verify: bar chart is preserved", { tags: ["@target"] }, () => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/collection/root");
|
||||
cy.findAllByTestId("collection-entry-name")
|
||||
.filter(`:contains(${Q2_NAME})`)
|
||||
.click();
|
||||
|
||||
X.assertRowCount("4");
|
||||
|
||||
cy.log("-- Assert that there are four bars --");
|
||||
H.chartPathWithFillColor("#A989C5").should("have.length", 4);
|
||||
H.echartsContainer()
|
||||
.find("text")
|
||||
.should("contain", "Average of Discount")
|
||||
.and("contain", "Products → Category")
|
||||
.and("contain", "Doohickey")
|
||||
.and("contain", "Gizmo")
|
||||
.and("contain", "$0")
|
||||
.and("contain", "$5.00");
|
||||
|
||||
H.snapshotCrossVersionDev("02-complete");
|
||||
});
|
||||
});
|
||||
90
e2e/cross-version/57/03-nested-model.cy.spec.ts
Normal file
90
e2e/cross-version/57/03-nested-model.cy.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Q2_NAME, Q3_NAME } from "../constants";
|
||||
|
||||
import * as X from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version questions - nested model", () => {
|
||||
it(
|
||||
"setup: creates a model based on another question",
|
||||
{ tags: ["@source"] },
|
||||
() => {
|
||||
H.restoreCrossVersionDev("02-complete");
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.log("-- Create a model from a previous question --");
|
||||
|
||||
cy.visit("/");
|
||||
H.newButton("Question").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findAllByRole("tab")
|
||||
.should("be.visible")
|
||||
.filter(":contains(Collections)")
|
||||
.click();
|
||||
});
|
||||
cy.findAllByTestId("picker-item").filter(`:contains(${Q2_NAME})`).click();
|
||||
|
||||
cy.log("-- Add aggregation --");
|
||||
H.addSummaryField({ metric: "Sum of ..." });
|
||||
X.selectFromPopover("Average of Discount");
|
||||
|
||||
H.visualize();
|
||||
cy.findByTestId("scalar-value").should("have.text", "$20.80");
|
||||
|
||||
X.saveQuestion(Q3_NAME);
|
||||
|
||||
cy.log("-- Turn question into a model --");
|
||||
H.openQuestionActions();
|
||||
X.selectFromPopover("Turn into a model");
|
||||
H.modal().button("Turn this into a model").click();
|
||||
cy.location("pathname").should("contain", "model");
|
||||
|
||||
cy.log("-- Edit model metadata --");
|
||||
H.openQuestionActions();
|
||||
X.selectFromPopover("Edit metadata");
|
||||
H.waitForLoaderToBeRemoved();
|
||||
cy.location("pathname").should("include", "columns");
|
||||
|
||||
H.renameColumn("Sum of Average of Discount", "Total Discount");
|
||||
|
||||
H.rightSidebar().findByDisplayValue("US Dollar").click();
|
||||
X.selectFromPopover("Euro");
|
||||
H.rightSidebar().findByDisplayValue("Euro").should("be.visible");
|
||||
|
||||
H.saveMetadataChanges();
|
||||
cy.location("pathname").should("not.include", "columns");
|
||||
|
||||
cy.findByTestId("scalar-value").should("have.text", "€20.80");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"verify: model custom metadata is preserved",
|
||||
{ tags: ["@target"] },
|
||||
() => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/collection/root");
|
||||
|
||||
H.getPinnedSection()
|
||||
.findByRole("link")
|
||||
.should("contain", Q3_NAME)
|
||||
.click();
|
||||
|
||||
cy.findByTestId("question-row-count").should(
|
||||
"have.text",
|
||||
"Showing 1 row",
|
||||
);
|
||||
|
||||
cy.log("-- Assert that the model metadata is preserved --");
|
||||
cy.findByTestId("scalar-value").should("have.text", "€20.80");
|
||||
cy.findByLabelText("Switch to data").click();
|
||||
|
||||
cy.findByTestId("scalar-value").should("not.exist");
|
||||
cy.findByTestId("header-cell").should("contain", "Total Discount (€)");
|
||||
|
||||
H.snapshotCrossVersionDev("03-complete");
|
||||
},
|
||||
);
|
||||
});
|
||||
123
e2e/cross-version/57/04-sql.cy.spec.ts
Normal file
123
e2e/cross-version/57/04-sql.cy.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Q4_SQL_NAME, SQL_QUERY } from "../constants";
|
||||
|
||||
import * as X from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version questions - sql", () => {
|
||||
it(
|
||||
"setup: creates a sql question with a snippet and a field filter variable",
|
||||
{ tags: ["@source"] },
|
||||
() => {
|
||||
H.restoreCrossVersionDev("03-complete");
|
||||
cy.signIn("admin", { setupCache: true });
|
||||
|
||||
cy.request("POST", "/api/native-query-snippet", {
|
||||
content: "REVIEWS.BODY IS NOT NULL",
|
||||
name: "Body Not Null",
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
H.newButton("SQL query").click();
|
||||
|
||||
cy.log("-- Create a SQL question --");
|
||||
H.NativeEditor.type(SQL_QUERY, {
|
||||
focus: true,
|
||||
allowFastSet: true,
|
||||
}).blur();
|
||||
|
||||
cy.log("-- Map the variable to a field filter --");
|
||||
cy.findByTestId("variable-type-select").click();
|
||||
X.selectFromPopover("Field Filter");
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("heading", { name: "Sample Database" }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("Reviews").click();
|
||||
});
|
||||
X.selectFromPopover("Rating");
|
||||
|
||||
H.NativeEditor.clickOnRun();
|
||||
X.assertRowCount("1,112");
|
||||
|
||||
cy.log("-- Hide the PK column --");
|
||||
cy.findByTestId("visualization-root")
|
||||
.findByTestId("table-header")
|
||||
.contains("PRODUCT_ID")
|
||||
.click();
|
||||
H.popover().icon("eye_crossed_out").click();
|
||||
|
||||
cy.log("-- Assert on the preview values in a table --");
|
||||
cy.findByTestId("visualization-root").within(() => {
|
||||
cy.findByTestId("table-header")
|
||||
.should("contain", "REVIEWER")
|
||||
.and("contain", "RATING")
|
||||
.and("not.contain", "PRODUCT_ID");
|
||||
|
||||
cy.findByTestId("table-body")
|
||||
.should("contain", "xavier")
|
||||
.and("contain", "4")
|
||||
.and("contain", "clement")
|
||||
.and("contain", "5");
|
||||
});
|
||||
|
||||
cy.log("-- Assert that the filter is mapped to a correct value --");
|
||||
cy.findByTestId("parameter-widget").should("contain", "Rating");
|
||||
|
||||
X.saveQuestion(Q4_SQL_NAME);
|
||||
},
|
||||
);
|
||||
|
||||
it("verify: sql question can be filtered", { tags: ["@target"] }, () => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/collection/root");
|
||||
|
||||
cy.findAllByTestId("collection-entry-name")
|
||||
.filter(`:contains(${Q4_SQL_NAME})`)
|
||||
.click();
|
||||
|
||||
cy.log("-- Assert that the columns are preserved --");
|
||||
X.assertRowCount("1,112");
|
||||
cy.findByTestId("visualization-root").within(() => {
|
||||
cy.findByTestId("table-header")
|
||||
.should("contain", "REVIEWER")
|
||||
.and("contain", "RATING")
|
||||
.and("not.contain", "PRODUCT_ID");
|
||||
|
||||
cy.findByTestId("table-body")
|
||||
.should("contain", "xavier")
|
||||
.and("contain", "4")
|
||||
.and("contain", "clement")
|
||||
.and("contain", "5");
|
||||
});
|
||||
|
||||
cy.log("-- Apply the filter --");
|
||||
cy.findByTestId("native-query-editor-container").within(() => {
|
||||
cy.findByText("This question is written in SQL.").should("be.visible");
|
||||
|
||||
cy.findByTestId("parameter-widget").should("contain", "Rating").click();
|
||||
});
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Perfecto").click();
|
||||
cy.findByText("Meh").click();
|
||||
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("qb-header").findByLabelText("Get Answer").click();
|
||||
|
||||
cy.log("-- Assert that the filter values are correctly applied --");
|
||||
X.assertRowCount("442");
|
||||
cy.findByTestId("parameter-widget")
|
||||
.should("contain", "Rating")
|
||||
.and("contain", "2 selections");
|
||||
cy.findByTestId("table-body")
|
||||
.and("contain", "clement")
|
||||
.and("contain", "5")
|
||||
.should("contain", "clement")
|
||||
.and("contain", "3")
|
||||
.should("not.contain", "xavier")
|
||||
.and("not.contain", "4");
|
||||
});
|
||||
});
|
||||
49
e2e/cross-version/57/helpers.ts
Normal file
49
e2e/cross-version/57/helpers.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
const { H } = cy;
|
||||
|
||||
export function startQuestionFromTable(table: string) {
|
||||
cy.visit("/");
|
||||
H.newButton("Question").click();
|
||||
H.modal().contains(table).should("be.visible").click();
|
||||
}
|
||||
|
||||
export function saveQuestion(name: string) {
|
||||
cy.log(`-- Save question: ${name}`);
|
||||
cy.intercept("POST", "/api/card").as("saveQuestion");
|
||||
cy.findByTestId("qb-header").button("Save").click();
|
||||
cy.findByTestId("save-question-modal").within(() => {
|
||||
cy.findByLabelText("Name").clear().type(name);
|
||||
cy.button("Save").click();
|
||||
cy.wait("@saveQuestion");
|
||||
});
|
||||
}
|
||||
|
||||
export function assertRowCount(count: string) {
|
||||
cy.findByTestId("question-row-count").should(
|
||||
"have.text",
|
||||
`Showing ${count} rows`,
|
||||
);
|
||||
}
|
||||
|
||||
export function selectFromPopover(item: string) {
|
||||
H.popover().contains(item).click();
|
||||
}
|
||||
|
||||
export function joinTables(baseTable: string, joinTable: string) {
|
||||
H.modal().within(() => {
|
||||
cy.findAllByRole("tab")
|
||||
.should("be.visible")
|
||||
.filter(":contains(Tables)")
|
||||
.click();
|
||||
cy.findAllByTestId("picker-item").filter(`:contains(${baseTable})`).click();
|
||||
});
|
||||
|
||||
H.join();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findAllByRole("tab")
|
||||
.should("be.visible")
|
||||
.filter(":contains(Tables)")
|
||||
.click();
|
||||
cy.findAllByTestId("picker-item").filter(`:contains(${joinTable})`).click();
|
||||
});
|
||||
}
|
||||
10
e2e/cross-version/constants.ts
Normal file
10
e2e/cross-version/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const Q1_NAME = "cv1-pivot";
|
||||
export const Q2_NAME = "cv2-joins";
|
||||
export const Q3_NAME = "cv3-nested";
|
||||
export const Q4_SQL_NAME = "cv4-sql";
|
||||
|
||||
export const SQL_QUERY = `
|
||||
SELECT PRODUCT_ID, REVIEWER, RATING
|
||||
FROM REVIEWS
|
||||
WHERE {{snippet: Body Not Null}}
|
||||
AND {{rating}}`;
|
||||
45
e2e/cross-version/cypress.config.js
Normal file
45
e2e/cross-version/cypress.config.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const { defineConfig } = require("cypress");
|
||||
|
||||
const { defaultConfig } = require("../support/config");
|
||||
|
||||
const isDevMode = !!process.env.CROSS_VERSION_DEV_MODE;
|
||||
const viewportWidth = 1600;
|
||||
const viewportHeight = 1200;
|
||||
|
||||
module.exports = defineConfig({
|
||||
e2e: {
|
||||
...defaultConfig,
|
||||
specPattern: "e2e/cross-version/latest/**/*.cy.spec.ts",
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
expose: {
|
||||
...defaultConfig.expose,
|
||||
CROSS_VERSION_DEV_MODE: isDevMode,
|
||||
},
|
||||
setupNodeEvents(on, config) {
|
||||
// Call the default setupNodeEvents first
|
||||
defaultConfig.setupNodeEvents(on, config);
|
||||
|
||||
// Set browser window size for headless mode to match viewport
|
||||
on("before:browser:launch", (browser, launchOptions) => {
|
||||
if (
|
||||
(browser.name === "chrome" || browser.name === "chromium") &&
|
||||
browser.isHeadless
|
||||
) {
|
||||
launchOptions.args.push(
|
||||
`--window-size=${viewportWidth},${viewportHeight}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (browser.name === "electron" && browser.isHeadless) {
|
||||
launchOptions.preferences.width = viewportWidth;
|
||||
launchOptions.preferences.height = viewportHeight;
|
||||
}
|
||||
|
||||
return launchOptions;
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
},
|
||||
});
|
||||
100
e2e/cross-version/latest/00-setup.cy.spec.ts
Normal file
100
e2e/cross-version/latest/00-setup.cy.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Cross-version test: Instance setup
|
||||
*
|
||||
* This must be the first test to run. It completes the Metabase setup wizard
|
||||
* in the source phase, and verifies the admin can authenticate in both phases.
|
||||
*
|
||||
* Uses credentials from e2e/support/cypress_data.js so cy.signInAsAdmin() works.
|
||||
*/
|
||||
|
||||
import { USERS } from "e2e/support/cypress_data";
|
||||
|
||||
const { email: ADMIN_EMAIL, password: ADMIN_PASSWORD } = USERS.admin;
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version: Instance setup", () => {
|
||||
it("setup: completes the setup wizard", { tags: ["@source"] }, () => {
|
||||
H.restoreCrossVersionDev("blank");
|
||||
cy.visit("/");
|
||||
|
||||
cy.request("GET", "/api/session/properties").then(({ body }) => {
|
||||
const setupToken = body["setup-token"];
|
||||
|
||||
cy.log("Setup token must exist on a fresh instance");
|
||||
expect(setupToken).to.be.a("string");
|
||||
|
||||
cy.request("POST", "/api/setup", {
|
||||
token: setupToken,
|
||||
user: {
|
||||
email: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
first_name: "Admin",
|
||||
last_name: "CrossVersion",
|
||||
},
|
||||
prefs: {
|
||||
site_name: "CrossVersion Test",
|
||||
site_locale: "en",
|
||||
allow_tracking: false,
|
||||
},
|
||||
}).then((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
});
|
||||
|
||||
cy.request("GET", "/api/user/current").then((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
expect(response.body.email).to.eq(ADMIN_EMAIL);
|
||||
|
||||
cy.log("Dismiss `it's ok to play around` modal for admin");
|
||||
cy.request("PUT", `/api/user/${response.body.id}/modal/qbnewb`);
|
||||
});
|
||||
|
||||
H.withSampleDatabase(({ ORDERS, PRODUCTS, REVIEWS }) => {
|
||||
cy.log("Remap display values to use foreign key as Product Title");
|
||||
cy.request("POST", `/api/field/${ORDERS.PRODUCT_ID}/dimension`, {
|
||||
name: "Product ID",
|
||||
type: "external",
|
||||
human_readable_field_id: PRODUCTS.TITLE,
|
||||
});
|
||||
|
||||
cy.log("Remap rating to use custom values");
|
||||
cy.request("POST", `/api/field/${REVIEWS.RATING}/dimension`, {
|
||||
type: "internal",
|
||||
name: "Rating",
|
||||
});
|
||||
|
||||
cy.request("POST", `/api/field/${REVIEWS.RATING}/values`, {
|
||||
values: [
|
||||
[1, "Awful"],
|
||||
[2, "Unpleasant"],
|
||||
[3, "Meh"],
|
||||
[4, "Enjoyable"],
|
||||
[5, "Perfecto"],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("verify: admin can authenticate", { tags: ["@target"] }, () => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/browse/databases");
|
||||
cy.findByTestId("database-browser").contains("Sample Database").click();
|
||||
|
||||
cy.log("Verify REVIEWS.Rating values display custom remapping");
|
||||
cy.findAllByRole("link").filter(":contains(Reviews)").click();
|
||||
cy.findAllByRole("gridcell")
|
||||
.should("contain", "Enjoyable")
|
||||
.and("contain", "Perfecto");
|
||||
|
||||
cy.go("back");
|
||||
cy.log("Verify ORDERS.ProductId FK is remapped to Product Title");
|
||||
cy.findAllByRole("link").filter(":contains(Orders)").click();
|
||||
cy.findAllByRole("gridcell")
|
||||
.should("contain", "Fantastic Wool Shirt")
|
||||
.and("contain", "Small Marble Hat");
|
||||
|
||||
H.snapshotCrossVersionDev("00-complete");
|
||||
});
|
||||
});
|
||||
73
e2e/cross-version/latest/01-pivot.cy.spec.ts
Normal file
73
e2e/cross-version/latest/01-pivot.cy.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Q1_NAME } from "../constants";
|
||||
|
||||
import * as X from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version questions - pivot", () => {
|
||||
it("setup: creates a pivot table", { tags: ["@source"] }, () => {
|
||||
H.restoreCrossVersionDev("00-complete");
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.log("-- Create a pivot table --");
|
||||
X.startQuestionFromTable("People");
|
||||
|
||||
cy.log(
|
||||
"-- Add filter: Narrow down the states that start with K (only two) --",
|
||||
);
|
||||
H.getNotebookStep("filter")
|
||||
.findByText("Add filters to narrow your answer")
|
||||
.click();
|
||||
X.selectFromPopover("State");
|
||||
cy.findByLabelText("Filter operator").click();
|
||||
X.selectFromPopover("Starts with");
|
||||
cy.findByLabelText("Filter value").type("K").blur();
|
||||
cy.button("Add filter").click();
|
||||
|
||||
cy.log("-- Add aggregation --");
|
||||
H.addSummaryField({ metric: "Count of rows" });
|
||||
|
||||
cy.log("-- Add breakouts --");
|
||||
H.addSummaryGroupingField({ field: "State" });
|
||||
H.addSummaryGroupingField({ field: "Source" });
|
||||
H.addSummaryGroupingField({ field: "Created At" });
|
||||
|
||||
H.visualize();
|
||||
|
||||
cy.log("-- Render as pivot table --");
|
||||
cy.intercept("POST", "/api/dataset/pivot").as("pivot");
|
||||
H.openVizTypeSidebar();
|
||||
H.vizTypeSidebar().within(() => {
|
||||
cy.findAllByRole("option").filter(":contains(Pivot Table)").click();
|
||||
cy.wait("@pivot");
|
||||
cy.button("Done").click();
|
||||
});
|
||||
|
||||
H.vizTypeSidebar().should("not.exist");
|
||||
X.assertRowCount("155");
|
||||
|
||||
X.saveQuestion(Q1_NAME);
|
||||
});
|
||||
|
||||
it("verify: pivot table is preserved", { tags: ["@target"] }, () => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/collection/root");
|
||||
|
||||
cy.log(`-- ${Q1_NAME}: Assert that the pivot table viz is preserved --`);
|
||||
cy.findAllByTestId("collection-entry-name")
|
||||
.filter(`:contains(${Q1_NAME})`)
|
||||
.click();
|
||||
|
||||
X.assertRowCount("155");
|
||||
|
||||
cy.findAllByTestId("pivot-table-cell")
|
||||
.should("contain", "State")
|
||||
.should("contain", "Created At: Month")
|
||||
.and("contain", "Organic")
|
||||
.and("contain", "Row totals")
|
||||
.and("contain", "KS");
|
||||
|
||||
H.snapshotCrossVersionDev("01-complete");
|
||||
});
|
||||
});
|
||||
75
e2e/cross-version/latest/02-joins.cy.spec.ts
Normal file
75
e2e/cross-version/latest/02-joins.cy.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Q2_NAME } from "../constants";
|
||||
|
||||
import * as X from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version questions - joins", () => {
|
||||
it("setup: creates a joined question", { tags: ["@source"] }, () => {
|
||||
H.restoreCrossVersionDev("01-complete");
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.log("-- Create a joined question --");
|
||||
|
||||
cy.visit("/");
|
||||
H.newButton("Question").click();
|
||||
|
||||
X.joinTables("Orders", "Products");
|
||||
|
||||
cy.log("-- Filter on the joined table --");
|
||||
H.getNotebookStep("filter")
|
||||
.findByText("Add filters to narrow your answer")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("heading", { name: "Products" }).click();
|
||||
cy.findByLabelText("Price").click();
|
||||
});
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByLabelText("Filter operator").should("have.text", "Between");
|
||||
cy.findByPlaceholderText("Min").type("0");
|
||||
cy.findByPlaceholderText("Min").type("50").blur();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
cy.log("-- Add aggregation --");
|
||||
H.addSummaryField({ metric: "Average of ..." });
|
||||
X.selectFromPopover("Discount");
|
||||
|
||||
cy.log("-- Add breakouts --");
|
||||
H.addSummaryGroupingField({ table: "Products", field: "Category" });
|
||||
|
||||
cy.log("-- Sort the columns --");
|
||||
cy.findByLabelText("Sort").click();
|
||||
X.selectFromPopover("Average of Discount");
|
||||
|
||||
H.visualize();
|
||||
X.assertRowCount("4");
|
||||
|
||||
X.saveQuestion(Q2_NAME);
|
||||
});
|
||||
|
||||
it("verify: bar chart is preserved", { tags: ["@target"] }, () => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/collection/root");
|
||||
cy.findAllByTestId("collection-entry-name")
|
||||
.filter(`:contains(${Q2_NAME})`)
|
||||
.click();
|
||||
|
||||
X.assertRowCount("4");
|
||||
|
||||
cy.log("-- Assert that there are four bars --");
|
||||
H.chartPathWithFillColor("#A989C5").should("have.length", 4);
|
||||
H.echartsContainer()
|
||||
.find("text")
|
||||
.should("contain", "Average of Discount")
|
||||
.and("contain", "Products → Category")
|
||||
.and("contain", "Doohickey")
|
||||
.and("contain", "Gizmo")
|
||||
.and("contain", "$0")
|
||||
.and("contain", "$5.00");
|
||||
|
||||
H.snapshotCrossVersionDev("02-complete");
|
||||
});
|
||||
});
|
||||
85
e2e/cross-version/latest/03-nested-model.cy.spec.ts
Normal file
85
e2e/cross-version/latest/03-nested-model.cy.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Q2_NAME, Q3_NAME } from "../constants";
|
||||
|
||||
import * as X from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version questions - nested model", () => {
|
||||
it(
|
||||
"setup: creates a model based on another question",
|
||||
{ tags: ["@source"] },
|
||||
() => {
|
||||
H.restoreCrossVersionDev("02-complete");
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.log("-- Create a model from a previous question --");
|
||||
|
||||
cy.visit("/");
|
||||
H.newButton("Question").click();
|
||||
|
||||
X.selectFromPopover("Our analytics");
|
||||
X.selectFromPopover(Q2_NAME);
|
||||
|
||||
cy.log("-- Add aggregation --");
|
||||
H.addSummaryField({ metric: "Sum of ..." });
|
||||
X.selectFromPopover("Average of Discount");
|
||||
|
||||
H.visualize();
|
||||
cy.findByTestId("scalar-value").should("have.text", "$20.80");
|
||||
|
||||
X.saveQuestion(Q3_NAME);
|
||||
|
||||
cy.log("-- Turn question into a model --");
|
||||
H.openQuestionActions();
|
||||
X.selectFromPopover("Turn into a model");
|
||||
H.modal().button("Turn this into a model").click();
|
||||
cy.location("pathname").should("contain", "model");
|
||||
|
||||
cy.log("-- Edit model metadata --");
|
||||
H.openQuestionActions();
|
||||
X.selectFromPopover("Edit metadata");
|
||||
H.waitForLoaderToBeRemoved();
|
||||
cy.location("pathname").should("include", "columns");
|
||||
|
||||
H.renameColumn("Sum of Average of Discount", "Total Discount");
|
||||
|
||||
H.rightSidebar().findByDisplayValue("US Dollar").click();
|
||||
X.selectFromPopover("Euro");
|
||||
H.rightSidebar().findByDisplayValue("Euro").should("be.visible");
|
||||
|
||||
H.saveMetadataChanges();
|
||||
cy.location("pathname").should("not.include", "columns");
|
||||
|
||||
cy.findByTestId("scalar-value").should("have.text", "€20.80");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"verify: model custom metadata is preserved",
|
||||
{ tags: ["@target"] },
|
||||
() => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/collection/root");
|
||||
|
||||
H.getPinnedSection()
|
||||
.findByRole("link")
|
||||
.should("contain", Q3_NAME)
|
||||
.click();
|
||||
|
||||
cy.findByTestId("question-row-count").should(
|
||||
"have.text",
|
||||
"Showing 1 row",
|
||||
);
|
||||
|
||||
cy.log("-- Assert that the model metadata is preserved --");
|
||||
cy.findByTestId("scalar-value").should("have.text", "€20.80");
|
||||
cy.findByLabelText("Switch to data").click();
|
||||
|
||||
cy.findByTestId("scalar-value").should("not.exist");
|
||||
cy.findByTestId("header-cell").should("contain", "Total Discount (€)");
|
||||
|
||||
H.snapshotCrossVersionDev("03-complete");
|
||||
},
|
||||
);
|
||||
});
|
||||
123
e2e/cross-version/latest/04-sql.cy.spec.ts
Normal file
123
e2e/cross-version/latest/04-sql.cy.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Q4_SQL_NAME, SQL_QUERY } from "../constants";
|
||||
|
||||
import * as X from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("Cross-version questions - sql", () => {
|
||||
it(
|
||||
"setup: creates a sql question with a snippet and a field filter variable",
|
||||
{ tags: ["@source"] },
|
||||
() => {
|
||||
H.restoreCrossVersionDev("03-complete");
|
||||
cy.signIn("admin", { setupCache: true });
|
||||
|
||||
cy.request("POST", "/api/native-query-snippet", {
|
||||
content: "REVIEWS.BODY IS NOT NULL",
|
||||
name: "Body Not Null",
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
H.newButton("SQL query").click();
|
||||
|
||||
cy.log("-- Create a SQL question --");
|
||||
H.NativeEditor.type(SQL_QUERY, {
|
||||
focus: true,
|
||||
allowFastSet: true,
|
||||
}).blur();
|
||||
|
||||
cy.log("-- Map the variable to a field filter --");
|
||||
cy.findByTestId("variable-type-select").click();
|
||||
X.selectFromPopover("Field Filter");
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("heading", { name: "Sample Database" }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("Reviews").click();
|
||||
});
|
||||
X.selectFromPopover("Rating");
|
||||
|
||||
H.NativeEditor.clickOnRun();
|
||||
X.assertRowCount("1,112");
|
||||
|
||||
cy.log("-- Hide the PK column --");
|
||||
cy.findByTestId("visualization-root")
|
||||
.findByTestId("table-header")
|
||||
.contains("PRODUCT_ID")
|
||||
.click();
|
||||
H.popover().icon("eye_crossed_out").click();
|
||||
|
||||
cy.log("-- Assert on the preview values in a table --");
|
||||
cy.findByTestId("visualization-root").within(() => {
|
||||
cy.findByTestId("table-header")
|
||||
.should("contain", "REVIEWER")
|
||||
.and("contain", "RATING")
|
||||
.and("not.contain", "PRODUCT_ID");
|
||||
|
||||
cy.findByTestId("table-body")
|
||||
.should("contain", "xavier")
|
||||
.and("contain", "4")
|
||||
.and("contain", "clement")
|
||||
.and("contain", "5");
|
||||
});
|
||||
|
||||
cy.log("-- Assert that the filter is mapped to a correct value --");
|
||||
cy.findByTestId("parameter-widget").should("contain", "Rating");
|
||||
|
||||
X.saveQuestion(Q4_SQL_NAME);
|
||||
},
|
||||
);
|
||||
|
||||
it("verify: sql question can be filtered", { tags: ["@target"] }, () => {
|
||||
cy.signIn("admin", { skipCache: true });
|
||||
|
||||
cy.visit("/collection/root");
|
||||
|
||||
cy.findAllByTestId("collection-entry-name")
|
||||
.filter(`:contains(${Q4_SQL_NAME})`)
|
||||
.click();
|
||||
|
||||
cy.log("-- Assert that the columns are preserved --");
|
||||
X.assertRowCount("1,112");
|
||||
cy.findByTestId("visualization-root").within(() => {
|
||||
cy.findByTestId("table-header")
|
||||
.should("contain", "REVIEWER")
|
||||
.and("contain", "RATING")
|
||||
.and("not.contain", "PRODUCT_ID");
|
||||
|
||||
cy.findByTestId("table-body")
|
||||
.should("contain", "xavier")
|
||||
.and("contain", "4")
|
||||
.and("contain", "clement")
|
||||
.and("contain", "5");
|
||||
});
|
||||
|
||||
cy.log("-- Apply the filter --");
|
||||
cy.findByTestId("native-query-editor-container").within(() => {
|
||||
cy.findByText("This question is written in SQL.").should("be.visible");
|
||||
|
||||
cy.findByTestId("parameter-widget").should("contain", "Rating").click();
|
||||
});
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Perfecto").click();
|
||||
cy.findByText("Meh").click();
|
||||
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("qb-header").findByLabelText("Get Answer").click();
|
||||
|
||||
cy.log("-- Assert that the filter values are correctly applied --");
|
||||
X.assertRowCount("442");
|
||||
cy.findByTestId("parameter-widget")
|
||||
.should("contain", "Rating")
|
||||
.and("contain", "2 selections");
|
||||
cy.findByTestId("table-body")
|
||||
.and("contain", "clement")
|
||||
.and("contain", "5")
|
||||
.should("contain", "clement")
|
||||
.and("contain", "3")
|
||||
.should("not.contain", "xavier")
|
||||
.and("not.contain", "4");
|
||||
});
|
||||
});
|
||||
40
e2e/cross-version/latest/helpers.ts
Normal file
40
e2e/cross-version/latest/helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const { H } = cy;
|
||||
|
||||
export function startQuestionFromTable(table: string) {
|
||||
cy.visit("/");
|
||||
H.newButton("Question").click();
|
||||
selectFromPopover("Sample Database");
|
||||
selectFromPopover(table);
|
||||
}
|
||||
|
||||
export function saveQuestion(name: string) {
|
||||
cy.log(`-- Save question: ${name}`);
|
||||
cy.intercept("POST", "/api/card").as("saveQuestion");
|
||||
cy.findByTestId("qb-header").button("Save").click();
|
||||
cy.findByTestId("save-question-modal").within(() => {
|
||||
cy.findByLabelText("Name").clear().type(name);
|
||||
cy.button("Save").click();
|
||||
cy.wait("@saveQuestion");
|
||||
});
|
||||
}
|
||||
|
||||
export function assertRowCount(count: string) {
|
||||
cy.findByTestId("question-row-count").should(
|
||||
"have.text",
|
||||
`Showing ${count} rows`,
|
||||
);
|
||||
}
|
||||
|
||||
export function selectFromPopover(item: string) {
|
||||
H.popover().contains(item).should("be.visible").click();
|
||||
}
|
||||
|
||||
export function joinTables(baseTable: string, joinTable: string) {
|
||||
selectFromPopover("Sample Database");
|
||||
selectFromPopover(baseTable);
|
||||
|
||||
H.join();
|
||||
|
||||
selectFromPopover("Sample Database");
|
||||
selectFromPopover(joinTable);
|
||||
}
|
||||
54
e2e/cross-version/run.sh
Executable file
54
e2e/cross-version/run.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run cross-version e2e tests
|
||||
#
|
||||
# Called by cross-version/test.sh - assumes Metabase is already running.
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh --phase source # Run setup tests (creates data)
|
||||
# ./run.sh --phase target # Run verification tests (checks data survived)
|
||||
#
|
||||
# Environment variables:
|
||||
# CYPRESS_BASE_URL - Metabase instance URL (default: http://localhost:3000)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
PHASE=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --phase <source|target>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--phase) PHASE="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$PHASE" ]] && { echo "Error: --phase is required"; usage; }
|
||||
[[ "$PHASE" != "source" && "$PHASE" != "target" ]] && { echo "Error: --phase must be 'source' or 'target'"; usage; }
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
export CYPRESS_BASE_URL="${CYPRESS_BASE_URL:-http://localhost:3000}"
|
||||
|
||||
CYPRESS_ARGS=(
|
||||
--config-file "e2e/cross-version/cypress.config.js"
|
||||
--env "grepTags=@${PHASE}"
|
||||
)
|
||||
|
||||
if [[ -n "${CYPRESS_SPEC_PATTERN:-}" ]]; then
|
||||
echo "[cypress] Running @${PHASE} tests from ${CYPRESS_SPEC_PATTERN}"
|
||||
CYPRESS_ARGS+=(--config "specPattern=${CYPRESS_SPEC_PATTERN}")
|
||||
else
|
||||
echo "[cypress] Running @${PHASE} tests against ${CYPRESS_BASE_URL}"
|
||||
fi
|
||||
|
||||
bunx cypress run "${CYPRESS_ARGS[@]}"
|
||||
@@ -5,7 +5,23 @@ const {
|
||||
component: embeddingSdkComponentTestConfig,
|
||||
} = require("./cypress-embedding-sdk-component-test.config");
|
||||
|
||||
const isFailFastEnabled = process.env.FAIL_FAST === "true";
|
||||
|
||||
module.exports = defineConfig({
|
||||
e2e: { ...defaultConfig, retries: 0 },
|
||||
component: { ...embeddingSdkComponentTestConfig, retries: 0 },
|
||||
e2e: {
|
||||
...defaultConfig,
|
||||
expose: {
|
||||
...defaultConfig.expose,
|
||||
FAIL_FAST: isFailFastEnabled,
|
||||
},
|
||||
retries: 0,
|
||||
},
|
||||
component: {
|
||||
...embeddingSdkComponentTestConfig,
|
||||
expose: {
|
||||
...embeddingSdkComponentTestConfig.expose,
|
||||
FAIL_FAST: isFailFastEnabled,
|
||||
},
|
||||
retries: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import "./commands";
|
||||
|
||||
const isCI = Cypress.expose("CI");
|
||||
const isNetworkThrottlingEnabled = Cypress.expose("ENABLE_NETWORK_THROTTLING");
|
||||
const isFailFastEnabled = Cypress.expose("FAIL_FAST");
|
||||
|
||||
// remove default html output on test failure
|
||||
configure({
|
||||
@@ -157,6 +158,12 @@ if (isCI) {
|
||||
require("cypress-terminal-report/src/installLogsCollector")(options);
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
if (isFailFastEnabled && this.currentTest.state === "failed") {
|
||||
Cypress.runner.stop();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
const isCurrentTesOss =
|
||||
this.currentTest._testConfig.unverifiedTestConfig.tags === "@OSS";
|
||||
|
||||
@@ -55,11 +55,10 @@ export function startNewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the query generated by clicking "New" and then choosing "Metric".
|
||||
* Navigate to the new metric creation page.
|
||||
*/
|
||||
export function startNewMetric() {
|
||||
const hash = newCardHash("metric");
|
||||
cy.visit(`/metric/query#${hash}`);
|
||||
cy.visit("/metric/new");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { codeMirrorHelpers } from "./e2e-codemirror-helpers";
|
||||
import { MetricPage } from "./e2e-metric-page-helpers";
|
||||
import { popover } from "./e2e-ui-elements-helpers";
|
||||
|
||||
const { H } = cy;
|
||||
@@ -14,8 +15,6 @@ const libraryPage = () => cy.findByTestId("library-page");
|
||||
const newSnippetPage = () => cy.findByTestId("new-snippet-page");
|
||||
const archivedSnippetsPage = () => cy.findByTestId("archived-snippets-page");
|
||||
const editSnippetPage = () => cy.findByTestId("edit-snippet-page");
|
||||
const metricOverviewPage = () => cy.findByTestId("metric-overview-page");
|
||||
const metricQueryEditor = () => cy.findByTestId("metric-query-editor");
|
||||
|
||||
export const DataStudio = {
|
||||
nav: () => cy.findByTestId("data-studio-nav"),
|
||||
@@ -96,24 +95,7 @@ export const DataStudio = {
|
||||
cancelButton: () => cy.findByRole("button", { name: "Cancel" }),
|
||||
editor: codeMirrorHelpers("snippet-editor", {}),
|
||||
},
|
||||
Metrics: {
|
||||
overviewPage: metricOverviewPage,
|
||||
queryEditor: metricQueryEditor,
|
||||
nameInput: () => metricQueryEditor().findByPlaceholderText("New metric"),
|
||||
saveButton: () =>
|
||||
metricQueryEditor().findByRole("button", { name: "Save" }),
|
||||
cancelButton: () =>
|
||||
metricQueryEditor().findByRole("button", { name: "Cancel" }),
|
||||
header: () => cy.findByTestId("metric-header"),
|
||||
moreMenu: () => DataStudio.Metrics.header().icon("ellipsis"),
|
||||
overviewTab: () => DataStudio.Metrics.header().findByText("Overview"),
|
||||
definitionTab: () => DataStudio.Metrics.header().findByText("Definition"),
|
||||
dependenciesTab: () =>
|
||||
DataStudio.Metrics.header().findByText("Dependencies"),
|
||||
cachingTab: () => DataStudio.Metrics.header().findByText("Caching"),
|
||||
overviewPageDescriptionSidebar: () =>
|
||||
metricOverviewPage().findByTestId("metric-description-sidebar"),
|
||||
},
|
||||
Metrics: MetricPage,
|
||||
Tables: {
|
||||
overviewPage: () => cy.findByTestId("table-overview-page"),
|
||||
fieldsPage: () => cy.findByTestId("table-fields-page"),
|
||||
|
||||
24
e2e/support/helpers/e2e-metric-page-helpers.ts
Normal file
24
e2e/support/helpers/e2e-metric-page-helpers.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const metricAboutPage = () => cy.findByTestId("metric-about-page");
|
||||
const metricOverviewPage = () => cy.findByTestId("metric-overview-page");
|
||||
const metricQueryEditor = () => cy.findByTestId("metric-query-editor");
|
||||
|
||||
export const MetricPage = {
|
||||
aboutPage: metricAboutPage,
|
||||
overviewPage: metricOverviewPage,
|
||||
queryEditor: metricQueryEditor,
|
||||
nameInput: () => metricQueryEditor().findByPlaceholderText("New metric"),
|
||||
saveButton: () => metricQueryEditor().findByRole("button", { name: "Save" }),
|
||||
cancelButton: () =>
|
||||
metricQueryEditor().findByRole("button", { name: "Cancel" }),
|
||||
header: () => cy.findByTestId("metric-header"),
|
||||
moreMenu: () => cy.findByLabelText("More options"),
|
||||
aboutTab: () => MetricPage.header().findByText("About"),
|
||||
overviewTab: () => MetricPage.header().findByText("Overview"),
|
||||
definitionTab: () => MetricPage.header().findByText("Definition"),
|
||||
dependenciesTab: () => MetricPage.header().findByText("Dependencies"),
|
||||
cachingTab: () => MetricPage.header().findByText("Caching"),
|
||||
historyTab: () => MetricPage.header().findByText("History"),
|
||||
aboutPageDescriptionSidebar: () =>
|
||||
metricAboutPage().findByTestId("metric-description-sidebar"),
|
||||
exploreLink: () => cy.findByTestId("explore-link"),
|
||||
};
|
||||
@@ -154,14 +154,10 @@ export function visitModel(id, { hasDataAccess = true } = {}) {
|
||||
*
|
||||
* @param {number} id
|
||||
*/
|
||||
export function visitMetric(id, { hasDataAccess = true } = {}) {
|
||||
export function visitMetric(id) {
|
||||
const alias = "metricQuery" + id;
|
||||
|
||||
if (hasDataAccess) {
|
||||
cy.intercept("POST", "/api/dataset").as(alias);
|
||||
} else {
|
||||
cy.intercept("POST", `/api/card/**/${id}/query`).as(alias);
|
||||
}
|
||||
cy.intercept("POST", `/api/card/${id}/query`).as(alias);
|
||||
|
||||
cy.visit(`/metric/${id}`);
|
||||
|
||||
|
||||
@@ -4,6 +4,26 @@ export function snapshot(name) {
|
||||
cy.request("POST", `/api/testing/snapshot/${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a snapshot during cross-version test development.
|
||||
*
|
||||
* This is a no-op in CI / production runs. It only takes effect when
|
||||
* the `CROSS_VERSION_DEV_MODE` env var is exposed through Cypress,
|
||||
* allowing developers to incrementally snapshot state while iterating
|
||||
* on cross-version test scenarios locally.
|
||||
*
|
||||
* @param {string} name - snapshot identifier
|
||||
*/
|
||||
export function snapshotCrossVersionDev(name) {
|
||||
if (!Cypress.expose?.("CROSS_VERSION_DEV_MODE")) {
|
||||
cy.log(
|
||||
`skipping cross-version snapshot "${name}" — not running in dev mode (production runs against Postgres)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
cy.request("POST", `/api/testing/snapshot/${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param { |
|
||||
@@ -30,3 +50,24 @@ export function restore(name = "default") {
|
||||
|
||||
return cy.request("POST", `/api/testing/restore/${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a snapshot during cross-version test development.
|
||||
*
|
||||
* This is a no-op in CI / production runs. It only takes effect when
|
||||
* the `CROSS_VERSION_DEV_MODE` env var is exposed through Cypress,
|
||||
* allowing developers to restore a previously-saved snapshot while
|
||||
* iterating on cross-version test scenarios locally.
|
||||
*
|
||||
* @param {string} [name="blank"] - snapshot identifier to restore
|
||||
*/
|
||||
export function restoreCrossVersionDev(name = "blank") {
|
||||
if (!Cypress.expose?.("CROSS_VERSION_DEV_MODE")) {
|
||||
cy.log(
|
||||
`skipping cross-version restore "${name}" — not running in dev mode (production runs against Postgres)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return cy.request("POST", `/api/testing/restore/${name}`);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export function openVizSettingsSidebar() {
|
||||
cy.findByTestId("viz-settings-button").click();
|
||||
}
|
||||
|
||||
export function vizTypeSidebar() {
|
||||
return cy.findByTestId("chart-type-sidebar");
|
||||
}
|
||||
|
||||
export function vizSettingsSidebar() {
|
||||
return cy.findByTestId("chartsettings-sidebar");
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export * from "./e2e-enterprise-helpers";
|
||||
export * from "./e2e-filter-helpers";
|
||||
export * from "./e2e-ldap-helpers";
|
||||
export * from "./e2e-metabot-helpers";
|
||||
export * from "./e2e-metric-page-helpers";
|
||||
export * from "./e2e-metrics-viewer-helpers";
|
||||
export * from "./e2e-misc-helpers";
|
||||
export * from "./e2e-mock-app-settings-helpers";
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("scenarios > embedding-sdk > guest-embed reproductions", () => {
|
||||
display,
|
||||
}).then(({ body: question }) => {
|
||||
cy.wrap(question.id).as("questionId");
|
||||
cy.wrap(question.entity_id).as("questionEntityId");
|
||||
});
|
||||
|
||||
cy.signOut();
|
||||
@@ -67,4 +68,22 @@ describe("scenarios > embedding-sdk > guest-embed reproductions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show question with a token containing entity id (EMB-1473)", () => {
|
||||
setup();
|
||||
|
||||
cy.get("@questionEntityId").then(async (questionEntityId) => {
|
||||
const token = await getSignedJwtForResource({
|
||||
resourceId: questionEntityId as unknown as number,
|
||||
resourceType: "question",
|
||||
});
|
||||
|
||||
mountGuestEmbedQuestion({ token, title: true, withDownloads: true });
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Product ID").should("be.visible");
|
||||
cy.findByText("Max of Quantity").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ const metabotRetryResponse = `0:"Retry: Here is the [question link](${adHocQuest
|
||||
describe("scenarios > embedding-sdk > metabot-question", () => {
|
||||
const setup = (response: string) => {
|
||||
signInAsAdminAndEnableEmbeddingSdk();
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
@@ -316,6 +317,7 @@ describe("scenarios > embedding-sdk > metabot-question", () => {
|
||||
describe("scenarios > embedding-sdk > metabot-question > enablement", () => {
|
||||
it("should show an error when embedded-metabot-enabled? is false", () => {
|
||||
signInAsAdminAndEnableEmbeddingSdk();
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
|
||||
cy.log("Disable embedded metabot");
|
||||
H.updateEnterpriseSettings({ "embedded-metabot-enabled?": false });
|
||||
|
||||
@@ -22,7 +22,10 @@ describe("scenarios > admin > settings > SSO > JWT", () => {
|
||||
/JWT Identity Provider URI/i,
|
||||
"https://example.test",
|
||||
);
|
||||
cy.button("Generate key").click();
|
||||
cy.button("Set up key").click();
|
||||
H.modal().within(() => {
|
||||
cy.button("Done").click();
|
||||
});
|
||||
cy.button("Save and enable").click();
|
||||
cy.wait("@updateSettings");
|
||||
H.goToAuthOverviewPage();
|
||||
@@ -73,13 +76,18 @@ describe("scenarios > admin > settings > SSO > JWT", () => {
|
||||
enableJwtAuth();
|
||||
cy.visit("/admin/settings/authentication/jwt");
|
||||
|
||||
cy.findByLabelText(/String used by the JWT signing key/i).should(
|
||||
"have.value",
|
||||
"**********00",
|
||||
);
|
||||
|
||||
cy.button("Regenerate key").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Regenerate JWT signing key?").should("exist");
|
||||
cy.findByText("Set up secret key").should("exist");
|
||||
cy.findByText(
|
||||
"This will cause existing tokens to stop working until the identity provider is updated with the new key.",
|
||||
).should("exist");
|
||||
cy.button("Yes").click();
|
||||
cy.button("Done").click();
|
||||
});
|
||||
cy.button("Save changes").click();
|
||||
cy.wait("@updateSettings");
|
||||
|
||||
@@ -381,7 +381,7 @@ describe("issue 16559", () => {
|
||||
cy.findByRole("tab", { name: "History" }).click();
|
||||
cy.log("Dashboard creation");
|
||||
cy.findByTestId("dashboard-history-list")
|
||||
.findAllByRole("listitem")
|
||||
.findAllByTestId("revision-history-event")
|
||||
.eq(0)
|
||||
.findByText("You created this.")
|
||||
.should("be.visible");
|
||||
@@ -399,7 +399,7 @@ describe("issue 16559", () => {
|
||||
H.openDashboardInfoSidebar().within(() => {
|
||||
cy.contains("button", "History").click();
|
||||
cy.findByTestId("dashboard-history-list")
|
||||
.findAllByRole("listitem")
|
||||
.findAllByTestId("revision-history-event")
|
||||
.eq(0)
|
||||
.findByText("You added a card.")
|
||||
.should("be.visible");
|
||||
@@ -414,7 +414,7 @@ describe("issue 16559", () => {
|
||||
cy.contains("button", "History").click();
|
||||
|
||||
cy.findByTestId("dashboard-history-list")
|
||||
.findAllByRole("listitem")
|
||||
.findAllByTestId("revision-history-event")
|
||||
.eq(0)
|
||||
.findByText(
|
||||
'You renamed this Dashboard from "16559 Dashboard" to "16559 Dashboard modified".',
|
||||
@@ -433,7 +433,7 @@ describe("issue 16559", () => {
|
||||
cy.contains("button", "History").click();
|
||||
|
||||
cy.findByTestId("dashboard-history-list")
|
||||
.findAllByRole("listitem")
|
||||
.findAllByTestId("revision-history-event")
|
||||
.eq(0)
|
||||
.findByText("You added a description.")
|
||||
.should("be.visible");
|
||||
@@ -451,7 +451,7 @@ describe("issue 16559", () => {
|
||||
cy.contains("button", "History").click();
|
||||
|
||||
cy.findByTestId("dashboard-history-list")
|
||||
.findAllByRole("listitem")
|
||||
.findAllByTestId("revision-history-event")
|
||||
.eq(0)
|
||||
.findByText("You set auto apply filters to false.")
|
||||
.should("be.visible");
|
||||
@@ -470,7 +470,7 @@ describe("issue 16559", () => {
|
||||
H.openDashboardInfoSidebar().within(() => {
|
||||
cy.contains("button", "History").click();
|
||||
cy.findByTestId("dashboard-history-list")
|
||||
.findAllByRole("listitem")
|
||||
.findAllByTestId("revision-history-event")
|
||||
.eq(0)
|
||||
.findByText("You moved this Dashboard to First collection.")
|
||||
.should("be.visible");
|
||||
|
||||
@@ -75,10 +75,16 @@ describe("Table editing", () => {
|
||||
TablePicker.getTable("Orders").click();
|
||||
|
||||
cy.log("publish the table and verify it's published");
|
||||
TablePicker.getTable("Orders")
|
||||
.findByTestId("table-published")
|
||||
.should("not.exist");
|
||||
cy.findByRole("button", { name: /Publish/ }).click();
|
||||
H.modal().findByText("Create my Library").click();
|
||||
H.modal().findByText("Publish this table").click();
|
||||
cy.wait("@publishTables");
|
||||
TablePicker.getTable("Orders")
|
||||
.findByTestId("table-published")
|
||||
.should("be.visible");
|
||||
H.undoToastListContainer().within(() => {
|
||||
cy.findByText("Published").should("be.visible");
|
||||
cy.findByRole("button", { name: /Go to Data/ }).click();
|
||||
@@ -93,6 +99,9 @@ describe("Table editing", () => {
|
||||
cy.findByRole("button", { name: /Unpublish/ }).click();
|
||||
H.modal().findByText("Unpublish this table").click();
|
||||
cy.wait("@unpublishTables");
|
||||
TablePicker.getTable("Orders")
|
||||
.findByTestId("table-published")
|
||||
.should("not.exist");
|
||||
H.DataStudio.nav().findByLabelText("Library").click();
|
||||
H.DataStudio.Library.allTableItems().should("have.length", 0);
|
||||
},
|
||||
@@ -105,6 +114,14 @@ describe("Table editing", () => {
|
||||
TablePicker.getDatabase("QA Postgres12").click();
|
||||
TablePicker.getTable("Orders").click();
|
||||
|
||||
cy.log("rename the table and verify the name is updated in the picker");
|
||||
H.DataModel.TableSection.getNameInput()
|
||||
.clear()
|
||||
.type("Renamed Orders")
|
||||
.blur();
|
||||
cy.wait("@updateTable");
|
||||
TablePicker.getTable("Renamed Orders").should("be.visible");
|
||||
|
||||
H.selectHasValue("Owner", "No owner").click();
|
||||
H.selectDropdown().contains("Bobby Tables").click();
|
||||
H.undoToastListContainer()
|
||||
@@ -131,7 +148,7 @@ describe("Table editing", () => {
|
||||
|
||||
// navigate away and back
|
||||
TablePicker.getTable("Products").click();
|
||||
TablePicker.getTable("Orders").click();
|
||||
TablePicker.getTable("Renamed Orders").click();
|
||||
|
||||
H.selectHasValue("Owner", "Bobby Tables");
|
||||
H.selectHasValue("Visibility layer", "Final");
|
||||
@@ -175,4 +192,63 @@ describe("Table editing", () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("with remote sync enabled", () => {
|
||||
it(
|
||||
"should update the tree after publishing, unpublishing, and renaming a table (metabase#69554)",
|
||||
{ tags: ["@external"] },
|
||||
() => {
|
||||
H.restore("mysql-8");
|
||||
H.activateToken("bleeding-edge");
|
||||
H.setupGitSync();
|
||||
H.configureGit("read-write");
|
||||
|
||||
H.DataModel.visitDataStudio();
|
||||
TablePicker.getDatabase("QA MySQL8").click();
|
||||
TablePicker.getTable("Orders").click();
|
||||
|
||||
cy.log("publish the table and verify the tree updates");
|
||||
cy.findByRole("button", { name: /Publish/ }).click();
|
||||
H.modal().findByText("Create my Library").click();
|
||||
H.modal().findByText("Publish this table").click();
|
||||
cy.wait("@publishTables");
|
||||
TablePicker.getTable("Orders")
|
||||
.findByTestId("table-published")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("unpublish the table and verify the tree updates");
|
||||
cy.findByRole("button", { name: /Unpublish/ }).click();
|
||||
H.modal().findByText("Unpublish this table").click();
|
||||
cy.wait("@unpublishTables");
|
||||
TablePicker.getTable("Orders")
|
||||
.findByTestId("table-published")
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("rename the table and verify the tree updates");
|
||||
H.DataModel.TableSection.getNameInput()
|
||||
.clear()
|
||||
.type("Renamed Orders")
|
||||
.blur();
|
||||
cy.wait("@updateTable");
|
||||
TablePicker.getTable("Renamed Orders").should("be.visible");
|
||||
|
||||
cy.log("update the owner and verify it is reflected");
|
||||
TablePicker.getTable("Renamed Orders").should(
|
||||
"not.contain.text",
|
||||
"Bobby Tables",
|
||||
);
|
||||
H.selectHasValue("Owner", "No owner").click();
|
||||
H.selectDropdown().contains("Bobby Tables").click();
|
||||
cy.wait("@updateTable");
|
||||
H.undoToastListContainer()
|
||||
.findByText("Table owner updated")
|
||||
.should("be.visible");
|
||||
H.selectHasValue("Owner", "Bobby Tables");
|
||||
TablePicker.getTable("Renamed Orders").should(
|
||||
"contain.text",
|
||||
"Bobby Tables",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,8 @@ describe("scenarios > data studio > library", () => {
|
||||
it("should let you move metrics into the library, even when empty", () => {
|
||||
H.createLibrary();
|
||||
H.createQuestion(TRUSTED_ORDERS_METRIC, { visitQuestion: true });
|
||||
H.openQuestionActions("Duplicate");
|
||||
H.DataStudio.Metrics.moreMenu().click();
|
||||
H.popover().findByText("Duplicate").click();
|
||||
H.modal().findByTestId("dashboard-and-collection-picker-button").click();
|
||||
|
||||
H.entityPickerModalItem(0, "Library").click();
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Save your metric").should("be.visible");
|
||||
cy.findByLabelText("Name").type("Total Revenue");
|
||||
cy.findByLabelText("Name").clear().type("Total Revenue");
|
||||
cy.findByLabelText("Description").type(
|
||||
"Sum of all order totals across the store",
|
||||
);
|
||||
@@ -65,14 +65,14 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
cy.log("Verify metric overview page");
|
||||
cy.url().should("match", /\/data-studio\/library\/metrics\/\d+$/);
|
||||
|
||||
H.DataStudio.Metrics.overviewPage().within(() => {
|
||||
H.DataStudio.Metrics.aboutPage().within(() => {
|
||||
cy.findAllByText("Total Revenue").should("have.length", 2); // breadcrumbs + header
|
||||
cy.findByText("Sum of all order totals across the store").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
|
||||
H.DataStudio.Metrics.overviewPageDescriptionSidebar().within(() => {
|
||||
H.DataStudio.Metrics.aboutPageDescriptionSidebar().within(() => {
|
||||
cy.findByText("Last edited by Bobby Tables").should("be.visible");
|
||||
|
||||
cy.findByText("Sample Database").should("be.visible");
|
||||
@@ -119,12 +119,12 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
.click();
|
||||
|
||||
cy.log("Verify metric overview page displays correct data");
|
||||
H.DataStudio.Metrics.overviewPage()
|
||||
H.DataStudio.Metrics.aboutPage()
|
||||
.findByDisplayValue("Trusted Orders Metric")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("Update the metric name");
|
||||
H.DataStudio.Metrics.overviewPage()
|
||||
H.DataStudio.Metrics.aboutPage()
|
||||
.findByDisplayValue("Trusted Orders Metric")
|
||||
.clear()
|
||||
.type("Updated Orders Metric{enter}");
|
||||
@@ -132,7 +132,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
cy.wait("@updateCard");
|
||||
|
||||
cy.log("Verify updated name appears in overview");
|
||||
H.DataStudio.Metrics.overviewPage()
|
||||
H.DataStudio.Metrics.aboutPage()
|
||||
.findByDisplayValue("Updated Orders Metric")
|
||||
.should("be.visible");
|
||||
|
||||
@@ -209,7 +209,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
.click();
|
||||
|
||||
cy.log("Verify metric is loaded before archiving");
|
||||
H.DataStudio.Metrics.overviewPage()
|
||||
H.DataStudio.Metrics.aboutPage()
|
||||
.findByDisplayValue("Trusted Orders Metric")
|
||||
.should("be.visible");
|
||||
|
||||
@@ -241,9 +241,6 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
|
||||
cy.log("Restore the metric");
|
||||
cy.findByRole("table").findByText("Trusted Orders Metric").click();
|
||||
|
||||
H.MetricsViewer.openMetricHomePage("Trusted Orders Metric");
|
||||
|
||||
cy.findByTestId("archive-banner").should("be.visible");
|
||||
cy.findByTestId("archive-banner").findByText("Restore").click();
|
||||
cy.wait("@updateCard");
|
||||
@@ -255,7 +252,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should view metric in question view via more menu", () => {
|
||||
it("should view metric in the metrics explorer view via more menu", () => {
|
||||
cy.log("Navigate to Data Studio Library");
|
||||
cy.visit("/data-studio/library");
|
||||
|
||||
@@ -265,15 +262,13 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
.click();
|
||||
|
||||
cy.log("Verify metric is loaded");
|
||||
H.DataStudio.Metrics.overviewPage()
|
||||
H.DataStudio.Metrics.aboutPage()
|
||||
.findByDisplayValue("Trusted Orders Metric")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("Verify View link opens in new tab");
|
||||
H.DataStudio.Metrics.moreMenu().click();
|
||||
H.popover()
|
||||
.findByText("View")
|
||||
.closest("a")
|
||||
H.DataStudio.Metrics.exploreLink()
|
||||
.should("have.attr", "target", "_blank")
|
||||
.should("have.attr", "href")
|
||||
.and("match", /\/explore\?metricId=\d+/);
|
||||
@@ -289,7 +284,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
.click();
|
||||
|
||||
cy.log("Verify metric is loaded");
|
||||
H.DataStudio.Metrics.overviewPage()
|
||||
H.DataStudio.Metrics.aboutPage()
|
||||
.findByDisplayValue("Trusted Orders Metric")
|
||||
.should("be.visible");
|
||||
|
||||
@@ -319,7 +314,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
H.modal().should("not.exist");
|
||||
|
||||
cy.log("Verify duplicate metric is created");
|
||||
H.DataStudio.Metrics.overviewPage()
|
||||
H.DataStudio.Metrics.aboutPage()
|
||||
.findAllByText("Trusted Orders Metric - Duplicate")
|
||||
.should("have.length", 2); // breadcrumbs + header
|
||||
|
||||
@@ -341,7 +336,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
.click();
|
||||
|
||||
cy.log("Verify metric is loaded");
|
||||
H.DataStudio.Metrics.overviewPage()
|
||||
H.DataStudio.Metrics.aboutPage()
|
||||
.findByDisplayValue("Trusted Orders Metric")
|
||||
.should("be.visible");
|
||||
|
||||
@@ -378,7 +373,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
});
|
||||
|
||||
cy.log("Verify we're on the new metric page");
|
||||
cy.url().should("match", /\/metric\/query/);
|
||||
cy.url().should("match", /\/metric\/new/);
|
||||
|
||||
cy.findByPlaceholderText(/Search for tables/).type("Orders");
|
||||
H.popover()
|
||||
@@ -412,7 +407,7 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
});
|
||||
|
||||
cy.log("Verify we're on the new metric page");
|
||||
cy.url().should("match", /\/metric\/query/);
|
||||
cy.url().should("match", /\/metric\/new/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -668,6 +668,7 @@ LIMIT
|
||||
});
|
||||
|
||||
it("should show the metabot button", () => {
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
visitTransformListPage();
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("Query builder").click();
|
||||
@@ -2589,10 +2590,9 @@ LIMIT
|
||||
cy.intercept("GET", "/api/transform/*").as("transformReload");
|
||||
cy.findByTestId("transform-history-list")
|
||||
.findByText(/created this/)
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.findByTestId("question-revert-button").click();
|
||||
});
|
||||
.closest('[data-testid="revision-history-event"]')
|
||||
.findByTestId("question-revert-button")
|
||||
.click();
|
||||
cy.wait(["@revert", "@transformReload"]);
|
||||
|
||||
cy.log("Verify transform was reverted");
|
||||
|
||||
@@ -41,11 +41,12 @@ describe("scenarios > dependencies > dependency checks", () => {
|
||||
describe("metrics", () => {
|
||||
it("should not show a warning when a change to a metric is backward-compatible with existing content", () => {
|
||||
createMetricWithDependentMbqlQuestionsAndTransforms();
|
||||
cy.get<number>("@metricId").then(H.visitMetric);
|
||||
H.openQuestionActions("Edit metric definition");
|
||||
cy.get<number>("@metricId").then((metricId) => {
|
||||
cy.visit(`/metric/${metricId}/query`);
|
||||
});
|
||||
H.getNotebookStep("summarize").findByText("Min of Score").click();
|
||||
H.popover().findByText("Name").click();
|
||||
cy.findByTestId("edit-bar").button("Save changes").click();
|
||||
cy.button("Save").click();
|
||||
cy.wait("@updateCard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -747,6 +747,7 @@ describe("documents", () => {
|
||||
|
||||
it("should support keyboard and mouse selection in suggestions without double highlight", () => {
|
||||
H.activateToken("bleeding-edge");
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
H.visitDocument("@documentId");
|
||||
|
||||
H.documentContent().click();
|
||||
@@ -1848,10 +1849,9 @@ describe("documents", () => {
|
||||
cy.intercept("GET", "/api/document/*").as("documentReload");
|
||||
cy.findByTestId("document-history-list")
|
||||
.findByText(/created this/)
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.findByTestId("question-revert-button").click();
|
||||
});
|
||||
.closest('[data-testid="revision-history-event"]')
|
||||
.findByTestId("question-revert-button")
|
||||
.click();
|
||||
cy.wait(["@revert", "@documentReload"]);
|
||||
|
||||
cy.log("Verify document was reverted");
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ALL_EXTERNAL_USERS_GROUP_ID } from "e2e/support/cypress_sample_instance
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
const { STATIC_ORDERS_ID, STATIC_PEOPLE_ID } = SAMPLE_DB_TABLES;
|
||||
const { STATIC_ORDERS_ID } = SAMPLE_DB_TABLES;
|
||||
const NON_SAMPLE_DB_NAME = "QA Postgres12";
|
||||
|
||||
describe("scenarios - embedding hub", () => {
|
||||
describe("checklist", () => {
|
||||
@@ -683,6 +684,7 @@ describe("scenarios - embedding hub", () => {
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByText("Configure data permissions and enable tenants")
|
||||
.closest("button")
|
||||
.scrollIntoView()
|
||||
.findByText("Done")
|
||||
.should("be.visible");
|
||||
|
||||
@@ -781,7 +783,7 @@ describe("scenarios - embedding hub", () => {
|
||||
});
|
||||
|
||||
it("shows autocomplete suggestions for organization_id based on selected field values", () => {
|
||||
H.restore("setup");
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
|
||||
@@ -821,7 +823,7 @@ describe("scenarios - embedding hub", () => {
|
||||
|
||||
cy.log("pick orders table");
|
||||
H.main().findByText("Pick a table").click();
|
||||
H.miniPicker().findByText("Sample Database").click();
|
||||
H.miniPicker().findByText(NON_SAMPLE_DB_NAME).click();
|
||||
H.miniPicker().findByText("Orders").click();
|
||||
|
||||
H.main().findByPlaceholderText("Pick a column").click();
|
||||
@@ -844,7 +846,7 @@ describe("scenarios - embedding hub", () => {
|
||||
// are only populated when the user goes through the "Select data" step
|
||||
// in the UI. Without it, the data permissions description won't show.
|
||||
it("shows RLS data permissions description in summary", () => {
|
||||
H.restore("setup");
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
|
||||
@@ -884,7 +886,7 @@ describe("scenarios - embedding hub", () => {
|
||||
|
||||
cy.log("pick Orders table and User ID column");
|
||||
H.main().findByText("Pick a table").click();
|
||||
H.miniPicker().findByText("Sample Database").click();
|
||||
H.miniPicker().findByText(NON_SAMPLE_DB_NAME).click();
|
||||
H.miniPicker().findByText("Orders").click();
|
||||
|
||||
H.main().findByPlaceholderText("Pick a column").click();
|
||||
@@ -927,7 +929,7 @@ describe("scenarios - embedding hub", () => {
|
||||
});
|
||||
|
||||
it("should create sandboxes for multiple tables via row-level security setup", () => {
|
||||
H.restore("setup");
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
|
||||
@@ -987,8 +989,10 @@ describe("scenarios - embedding hub", () => {
|
||||
|
||||
cy.log("Our analytics should be hidden in the table picker");
|
||||
H.miniPicker().findByText("Our analytics").should("not.exist");
|
||||
cy.log("Sample Database should be hidden in the table picker");
|
||||
H.miniPicker().findByText("Sample Database").should("not.exist");
|
||||
|
||||
H.miniPicker().findByText("Sample Database").click();
|
||||
H.miniPicker().findByText(NON_SAMPLE_DB_NAME).click();
|
||||
H.miniPicker().findByText("Orders").click();
|
||||
|
||||
H.main().findByPlaceholderText("Pick a column").click();
|
||||
@@ -998,7 +1002,7 @@ describe("scenarios - embedding hub", () => {
|
||||
H.main().findByText("Add table").click();
|
||||
H.main().findAllByText("Pick a table").first().click();
|
||||
|
||||
H.miniPicker().findByText("Sample Database").click();
|
||||
H.miniPicker().findByText(NON_SAMPLE_DB_NAME).click();
|
||||
|
||||
cy.log("orders should be hidden as it's already selected");
|
||||
H.miniPicker().findByText("Orders").should("not.exist");
|
||||
@@ -1026,57 +1030,60 @@ describe("scenarios - embedding hub", () => {
|
||||
// 1. The admin permissions UI is complex and would add significant test time
|
||||
// 2. This test focuses on the onboarding flow, not the permissions UI
|
||||
cy.log("access policies should be created");
|
||||
cy.request("GET", "/api/mt/gtap").should((response) => {
|
||||
const policies = response.body;
|
||||
expect(policies.length).to.be.at.least(2);
|
||||
H.getTableId({ databaseId: WRITABLE_DB_ID, name: "orders" }).then(
|
||||
(ordersTableId) => {
|
||||
H.getTableId({ databaseId: WRITABLE_DB_ID, name: "people" }).then(
|
||||
(peopleTableId) => {
|
||||
cy.request("GET", "/api/mt/gtap").should((response) => {
|
||||
const policies = response.body;
|
||||
expect(policies.length).to.be.at.least(2);
|
||||
|
||||
const orderPolicy = policies.find(
|
||||
(policy: { table_id: number }) =>
|
||||
policy.table_id === STATIC_ORDERS_ID,
|
||||
);
|
||||
const orderPolicy = policies.find(
|
||||
(policy: { table_id: number }) =>
|
||||
policy.table_id === ordersTableId,
|
||||
);
|
||||
|
||||
const peoplePolicy = policies.find(
|
||||
(policy: { table_id: number }) =>
|
||||
policy.table_id === STATIC_PEOPLE_ID,
|
||||
);
|
||||
const peoplePolicy = policies.find(
|
||||
(policy: { table_id: number }) =>
|
||||
policy.table_id === peopleTableId,
|
||||
);
|
||||
|
||||
expect(orderPolicy).to.exist;
|
||||
expect(peoplePolicy).to.exist;
|
||||
expect(orderPolicy).to.exist;
|
||||
expect(peoplePolicy).to.exist;
|
||||
|
||||
expect(orderPolicy.attribute_remappings).to.have.property(
|
||||
"organization_id",
|
||||
);
|
||||
expect(orderPolicy.attribute_remappings).to.have.property(
|
||||
"organization_id",
|
||||
);
|
||||
|
||||
expect(peoplePolicy.attribute_remappings).to.have.property(
|
||||
"organization_id",
|
||||
);
|
||||
});
|
||||
expect(peoplePolicy.attribute_remappings).to.have.property(
|
||||
"organization_id",
|
||||
);
|
||||
});
|
||||
|
||||
cy.request(
|
||||
"GET",
|
||||
`/api/permissions/graph/group/${ALL_EXTERNAL_USERS_GROUP_ID}`,
|
||||
).should((response) => {
|
||||
const graph = response.body;
|
||||
cy.request(
|
||||
"GET",
|
||||
`/api/permissions/graph/group/${ALL_EXTERNAL_USERS_GROUP_ID}`,
|
||||
).should((response) => {
|
||||
const graph = response.body;
|
||||
|
||||
const permissions = graph.groups[ALL_EXTERNAL_USERS_GROUP_ID!][1];
|
||||
expect(permissions).to.exist;
|
||||
const permissions =
|
||||
graph.groups[ALL_EXTERNAL_USERS_GROUP_ID!][WRITABLE_DB_ID];
|
||||
expect(permissions).to.exist;
|
||||
|
||||
// deep.equal ensures only the selected tables have permissions —
|
||||
// non-selected tables should be blocked (omitted from the response)
|
||||
expect(permissions["view-data"]).to.deep.equal({
|
||||
PUBLIC: {
|
||||
[STATIC_ORDERS_ID]: "sandboxed",
|
||||
[STATIC_PEOPLE_ID]: "sandboxed",
|
||||
},
|
||||
});
|
||||
expect(permissions["view-data"].public).to.deep.equal({
|
||||
[ordersTableId]: "sandboxed",
|
||||
[peopleTableId]: "sandboxed",
|
||||
});
|
||||
|
||||
expect(permissions["create-queries"]).to.deep.equal({
|
||||
PUBLIC: {
|
||||
[STATIC_ORDERS_ID]: "query-builder",
|
||||
[STATIC_PEOPLE_ID]: "query-builder",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(permissions["create-queries"].public).to.deep.equal({
|
||||
[ordersTableId]: "query-builder",
|
||||
[peopleTableId]: "query-builder",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
H.main()
|
||||
.findByRole("listitem", {
|
||||
@@ -1088,7 +1095,7 @@ describe("scenarios - embedding hub", () => {
|
||||
});
|
||||
|
||||
it("should update existing sandboxes when changing column selection", () => {
|
||||
H.restore("setup");
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
|
||||
@@ -1116,29 +1123,57 @@ describe("scenarios - embedding hub", () => {
|
||||
});
|
||||
|
||||
cy.log("create an existing sandbox for Orders table via API");
|
||||
cy.request("GET", "/api/permissions/group").then((response) => {
|
||||
const allExternalUsersGroup = response.body.find(
|
||||
(g: { magic_group_type: string }) =>
|
||||
g.magic_group_type === "all-external-users",
|
||||
);
|
||||
H.getTableId({ databaseId: WRITABLE_DB_ID, name: "orders" }).then(
|
||||
(ordersTableId) => {
|
||||
cy.wrap(ordersTableId).as("ordersTableId");
|
||||
|
||||
cy.request("POST", "/api/mt/gtap", {
|
||||
table_id: STATIC_ORDERS_ID,
|
||||
group_id: allExternalUsersGroup.id,
|
||||
card_id: null,
|
||||
attribute_remappings: {
|
||||
organization_id: ["dimension", ["field", 2, null]], // USER_ID field
|
||||
},
|
||||
});
|
||||
});
|
||||
H.getFieldId({ tableId: ordersTableId, name: "user_id" }).then(
|
||||
(userIdFieldId) => {
|
||||
H.getFieldId({ tableId: ordersTableId, name: "product_id" }).then(
|
||||
(productIdFieldId) => {
|
||||
cy.wrap(productIdFieldId).as("productIdFieldId");
|
||||
|
||||
cy.request("GET", "/api/permissions/group").then(
|
||||
(response) => {
|
||||
const allExternalUsersGroup = response.body.find(
|
||||
(g: { magic_group_type: string }) =>
|
||||
g.magic_group_type === "all-external-users",
|
||||
);
|
||||
|
||||
cy.request("POST", "/api/mt/gtap", {
|
||||
table_id: ordersTableId,
|
||||
group_id: allExternalUsersGroup.id,
|
||||
card_id: null,
|
||||
attribute_remappings: {
|
||||
organization_id: [
|
||||
"dimension",
|
||||
["field", userIdFieldId, null],
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cy.visit("/admin/embedding/setup-guide/permissions");
|
||||
|
||||
cy.log("wait for checklist data to load before interacting with steps");
|
||||
H.main()
|
||||
.findByRole("listitem", {
|
||||
name: /Enable multi-tenant user strategy/,
|
||||
timeout: 10_000,
|
||||
})
|
||||
.should("have.attr", "data-completed", "true");
|
||||
|
||||
cy.log("open the data segregation strategy step");
|
||||
H.main()
|
||||
.findByText("Which data segregation strategy does your database use?")
|
||||
.scrollIntoView()
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("select row and column level security strategy");
|
||||
@@ -1154,7 +1189,7 @@ describe("scenarios - embedding hub", () => {
|
||||
|
||||
cy.log("Select the same Orders table");
|
||||
H.main().findByText("Pick a table").click();
|
||||
H.miniPicker().findByText("Sample Database").click();
|
||||
H.miniPicker().findByText(NON_SAMPLE_DB_NAME).click();
|
||||
H.miniPicker().findByText("Orders").click();
|
||||
|
||||
cy.log("select Product ID column instead of User ID");
|
||||
@@ -1169,23 +1204,26 @@ describe("scenarios - embedding hub", () => {
|
||||
H.undoToast().should("not.exist");
|
||||
|
||||
cy.log("sandbox should be updated and not created");
|
||||
cy.request("GET", "/api/mt/gtap").should((response) => {
|
||||
const policies = response.body;
|
||||
cy.get<number>("@ordersTableId").then((ordersTableId) => {
|
||||
cy.get<number>("@productIdFieldId").then((productIdFieldId) => {
|
||||
cy.request("GET", "/api/mt/gtap").should((response) => {
|
||||
const policies = response.body;
|
||||
|
||||
const orderPolicies = policies.filter(
|
||||
(policy: { table_id: number }) =>
|
||||
policy.table_id === STATIC_ORDERS_ID,
|
||||
);
|
||||
const orderPolicies = policies.filter(
|
||||
(policy: { table_id: number }) =>
|
||||
policy.table_id === ordersTableId,
|
||||
);
|
||||
|
||||
// should only have one sandbox for Orders table
|
||||
expect(orderPolicies.length).to.equal(1);
|
||||
// should only have one sandbox for Orders table
|
||||
expect(orderPolicies.length).to.equal(1);
|
||||
|
||||
const [orderPolicy] = orderPolicies;
|
||||
const tenantFieldRef = orderPolicy.attribute_remappings.organization_id;
|
||||
const [orderPolicy] = orderPolicies;
|
||||
const tenantFieldRef =
|
||||
orderPolicy.attribute_remappings.organization_id;
|
||||
|
||||
// attribute_remappings should reference field 3 (PRODUCT_ID),
|
||||
// not field 2 (USER_ID)
|
||||
expect(tenantFieldRef[1][1]).to.equal(3);
|
||||
expect(tenantFieldRef[1][1]).to.equal(productIdFieldId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
H.main()
|
||||
@@ -1560,6 +1598,89 @@ describe("scenarios - embedding hub", () => {
|
||||
)
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("reopens the create tenants step after changing the segregation strategy", () => {
|
||||
H.addPostgresDatabase("QA Postgres12");
|
||||
|
||||
cy.log("pre-complete RLS and create a tenant");
|
||||
cy.sandboxTable({
|
||||
table_id: STATIC_ORDERS_ID,
|
||||
group_id: ALL_EXTERNAL_USERS_GROUP_ID,
|
||||
});
|
||||
cy.request("POST", "/api/ee/tenant", {
|
||||
name: "Legacy Tenant",
|
||||
slug: "legacy-tenant",
|
||||
attributes: {
|
||||
organization_id: "legacy-org",
|
||||
},
|
||||
});
|
||||
|
||||
cy.intercept("PUT", "/api/permissions/graph").as(
|
||||
"updatePermissionsGraph",
|
||||
);
|
||||
cy.intercept("GET", "/api/ee/embedding-hub/checklist").as("getChecklist");
|
||||
|
||||
cy.visit("/admin/embedding/setup-guide/permissions");
|
||||
|
||||
cy.log("reopen the strategy step and switch to connection impersonation");
|
||||
H.main()
|
||||
.findByRole("listitem", {
|
||||
name: "Which data segregation strategy does your database use?",
|
||||
})
|
||||
.click();
|
||||
|
||||
H.main()
|
||||
.findByRole("radio", { name: /Connection impersonation/ })
|
||||
.scrollIntoView()
|
||||
.click();
|
||||
|
||||
H.main()
|
||||
.findByRole("button", { name: "Use connection impersonation" })
|
||||
.scrollIntoView()
|
||||
.click();
|
||||
|
||||
cy.log("complete the new select-data step");
|
||||
H.main().findByPlaceholderText("Pick a database").click();
|
||||
H.popover().findByText("QA Postgres12").click();
|
||||
H.main().findByRole("button", { name: "Next" }).click();
|
||||
|
||||
cy.wait("@updatePermissionsGraph");
|
||||
|
||||
cy.log("create tenants should reopen instead of skipping to summary");
|
||||
H.main()
|
||||
.findByRole("listitem", {
|
||||
name: "Create tenants",
|
||||
timeout: 10_000,
|
||||
})
|
||||
.should("have.attr", "aria-current", "step");
|
||||
|
||||
H.main()
|
||||
.findByRole("listitem", { name: "Summary" })
|
||||
.should("not.have.attr", "aria-current", "step");
|
||||
|
||||
cy.log("creating a new tenant should go to summary");
|
||||
H.main().within(() => {
|
||||
cy.findByPlaceholderText("Tenant name").clear().type("Acme Corp");
|
||||
cy.findByPlaceholderText("tenant_role").type("acme_role");
|
||||
cy.findByPlaceholderText("tenant-slug").clear().type("acme-corp");
|
||||
});
|
||||
|
||||
H.main().findByRole("button", { name: "Create tenants" }).click();
|
||||
|
||||
H.undoToast()
|
||||
.findByText("Tenants created successfully")
|
||||
.should("be.visible");
|
||||
|
||||
H.main()
|
||||
.findByRole("listitem", { name: "Summary", timeout: 10_000 })
|
||||
.should("have.attr", "aria-current", "step");
|
||||
|
||||
H.main()
|
||||
.contains(
|
||||
"All users in Acme Corp will connect using the acme_role database role.",
|
||||
)
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("database routing create tenants step", () => {
|
||||
@@ -1677,7 +1798,6 @@ describe("scenarios - embedding hub", () => {
|
||||
expect(body["jwt-identity-provider-uri"]).to.equal(
|
||||
"https://jwt.example.com/auth",
|
||||
);
|
||||
expect(body["jwt-shared-secret"]).to.be.a("string");
|
||||
expect(body["jwt-group-sync"]).to.equal(true);
|
||||
});
|
||||
|
||||
@@ -1688,12 +1808,15 @@ describe("scenarios - embedding hub", () => {
|
||||
|
||||
cy.log("valid signing key should be shown");
|
||||
H.main().within(() => {
|
||||
cy.findByLabelText(/JWT Signing Key/i)
|
||||
.should("be.visible")
|
||||
.should("have.attr", "value")
|
||||
.and("have.length.at.least", 32);
|
||||
cy.findByText(
|
||||
/This example code for Node.js sets up an endpoint using Express/,
|
||||
).should("be.visible");
|
||||
|
||||
cy.findByRole("button", { name: "Next" }).click();
|
||||
cy.log("express.js code snippet should be shown inline");
|
||||
cy.contains("METABASE_JWT_SHARED_SECRET").should("exist");
|
||||
cy.contains("METABASE_INSTANCE_URL").should("exist");
|
||||
|
||||
cy.findByRole("button", { name: "Next" }).scrollIntoView().click();
|
||||
});
|
||||
|
||||
cy.log("step 2 should be complete");
|
||||
|
||||
@@ -26,6 +26,7 @@ describe(suiteTitle, () => {
|
||||
|
||||
H.updateSetting("enable-embedding-simple", true);
|
||||
H.updateSetting("enable-embedding-static", true);
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
|
||||
cy.intercept("GET", "/api/dashboard/*").as("dashboard");
|
||||
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
|
||||
|
||||
@@ -90,6 +90,7 @@ describe(suiteTitle, () => {
|
||||
H.activateToken("bleeding-edge");
|
||||
H.enableTracking();
|
||||
H.updateSetting("enable-embedding-simple", true);
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
|
||||
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
|
||||
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
|
||||
|
||||
@@ -302,11 +302,19 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
|
||||
<metabase-question question-id="${questionId}" drills />
|
||||
`);
|
||||
|
||||
cy.wait("@getCardQuery");
|
||||
|
||||
// Wait for the table to finish rendering before interacting,
|
||||
// as column auto-sizing can cause re-renders that detach elements.
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("table-root")
|
||||
.should("have.attr", "data-rows-count", "5");
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findAllByText("37.65")
|
||||
.first()
|
||||
.should("be.visible")
|
||||
.click();
|
||||
.click({ force: true });
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText(/Filter by this value/)
|
||||
.should("be.visible");
|
||||
@@ -424,6 +432,10 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
|
||||
});
|
||||
|
||||
describe("<metabase-metabot>", () => {
|
||||
beforeEach(() => {
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
});
|
||||
|
||||
it("should handle scrolling gracefully (metabase#67399)", () => {
|
||||
const question = `
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
@@ -455,8 +467,6 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
|
||||
});
|
||||
|
||||
it("should load the embedded Metabot component", () => {
|
||||
cy.request("PUT", "/api/setting/use-native-agent", { value: true });
|
||||
|
||||
H.visitCustomHtmlPage(`
|
||||
${H.getNewEmbedScriptTag()}
|
||||
${H.getNewEmbedConfigurationScript()}
|
||||
@@ -467,9 +477,9 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
|
||||
cy.log("metabot chat should be interactive");
|
||||
cy.findByText("Ask questions to AI.").should("be.visible");
|
||||
cy.findByPlaceholderText("Ask AI a question...").type("Foo{enter}");
|
||||
cy.findByText(
|
||||
"Sorry, an error occurred: No OpenRouter API key is set. If this persists, please contact your administrator.",
|
||||
).should("be.visible");
|
||||
cy.findByText(/Sorry, an error occurred:.*If this persists/).should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
cy.log(
|
||||
"uses sidebar layout by default when no layout attribute is provided",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import {
|
||||
ORDERS_DASHBOARD_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
import type { MetabaseTheme } from "metabase/embedding-sdk/theme/MetabaseTheme";
|
||||
|
||||
const { H } = cy;
|
||||
@@ -69,6 +72,65 @@ describe("scenarios > embedding > sdk iframe embedding > theming", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should measure table column widths based on themed font size", () => {
|
||||
const SMALL_FONT_THEME = {
|
||||
components: {
|
||||
table: { cell: { fontSize: "10px" } },
|
||||
},
|
||||
} as const satisfies MetabaseTheme;
|
||||
|
||||
let defaultColumnWidth: number;
|
||||
|
||||
cy.log("1. load with default theme and measure column width");
|
||||
|
||||
const defaultFrame = H.loadSdkIframeEmbedTestPage({
|
||||
elements: [
|
||||
{
|
||||
component: "metabase-question",
|
||||
attributes: { questionId: ORDERS_QUESTION_ID },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
cy.wait("@getCardQuery");
|
||||
|
||||
defaultFrame.within(() => {
|
||||
cy.findAllByTestId("header-cell")
|
||||
.filter(":contains('Product ID')")
|
||||
.first()
|
||||
.then(($el) => {
|
||||
defaultColumnWidth = $el[0].getBoundingClientRect().width;
|
||||
expect(defaultColumnWidth).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
cy.log("2. load with smaller font theme and verify column is narrower");
|
||||
|
||||
const themedFrame = H.loadSdkIframeEmbedTestPage({
|
||||
elements: [
|
||||
{
|
||||
component: "metabase-question",
|
||||
attributes: { questionId: ORDERS_QUESTION_ID },
|
||||
},
|
||||
],
|
||||
metabaseConfig: {
|
||||
theme: SMALL_FONT_THEME,
|
||||
},
|
||||
});
|
||||
|
||||
cy.wait("@getCardQuery");
|
||||
|
||||
themedFrame.within(() => {
|
||||
cy.findAllByTestId("header-cell")
|
||||
.filter(":contains('Product ID')")
|
||||
.first()
|
||||
.then(($el) => {
|
||||
const themedColumnWidth = $el[0].getBoundingClientRect().width;
|
||||
expect(themedColumnWidth).to.be.lessThan(defaultColumnWidth);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle dynamic theme updates", () => {
|
||||
const THEME_SWITCHER_HTML = `
|
||||
<div>
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("issue 35954", () => {
|
||||
cy.findByRole("tab", { name: "History" }).click();
|
||||
|
||||
cy.findByTestId("saved-question-history-list")
|
||||
.find("li")
|
||||
.findAllByTestId("revision-history-event")
|
||||
.filter(":contains(You created this)")
|
||||
.findByTestId("question-revert-button")
|
||||
.click();
|
||||
@@ -173,7 +173,7 @@ describe("issue 35954", () => {
|
||||
H.questionInfoButton().click();
|
||||
cy.findByRole("tab", { name: "History" }).click();
|
||||
cy.findByTestId("saved-question-history-list")
|
||||
.find("li")
|
||||
.findAllByTestId("revision-history-event")
|
||||
.filter(":contains(You edited this)")
|
||||
.findByTestId("question-revert-button")
|
||||
.click();
|
||||
|
||||
@@ -18,208 +18,195 @@ const allOrdersQuestion = {
|
||||
};
|
||||
|
||||
describe("Metabot Query Builder", () => {
|
||||
describe("OSS", { tags: "@OSS" }, () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
|
||||
});
|
||||
|
||||
it("should redirect to notebook when llm-metabot-configured? is false", () => {
|
||||
H.updateSetting("llm-anthropic-api-key", "");
|
||||
cy.visit("/question/ask");
|
||||
cy.url().should("include", "/question#");
|
||||
cy.findByTestId("metabot-send-message").should("not.exist");
|
||||
});
|
||||
|
||||
it("should redirect to notebook when metabot-enabled? is false", () => {
|
||||
H.updateSetting("metabot-enabled?", false);
|
||||
|
||||
cy.log(
|
||||
"visiting '/question/ask' should redirect to notebook when metabot is disabled",
|
||||
);
|
||||
cy.visit("/question/ask");
|
||||
cy.url().should("include", "/question#");
|
||||
cy.findByTestId("metabot-send-message").should("not.exist");
|
||||
});
|
||||
|
||||
it("should not show AI exploration in new button when metabot is disabled", () => {
|
||||
H.updateSetting("metabot-enabled?", false);
|
||||
cy.visit("/");
|
||||
|
||||
cy.log("'AI exploration' option should not appear in new button");
|
||||
H.newButton().click();
|
||||
H.popover().findByText("AI exploration").should("not.exist");
|
||||
});
|
||||
|
||||
it("should be able to successfully generate a notebook query", () => {
|
||||
// visit home page
|
||||
cy.visit("/");
|
||||
|
||||
// go to new button and click "AI exploration"
|
||||
H.newButton("AI exploration").click();
|
||||
|
||||
// should show page
|
||||
cy.url().should("include", "/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// should have disabled send button
|
||||
cy.findByTestId("metabot-send-message").should("be.disabled");
|
||||
|
||||
// should be able to type into the input
|
||||
metabotPromptInput().type("Show me all orders");
|
||||
|
||||
// should be able to send prompt
|
||||
const questionHash = H.adhocQuestionHash(allOrdersQuestion);
|
||||
H.mockMetabotResponse({
|
||||
body: mockNavigateToResponse(`/question#${questionHash}`),
|
||||
delay: 100,
|
||||
});
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
|
||||
// should see loading state, send button should be disabled
|
||||
cy.findByTestId("metabot-send-message").should("be.disabled");
|
||||
cy.findByTestId("metabot-send-message").should(
|
||||
"have.attr",
|
||||
"data-loading",
|
||||
"true",
|
||||
);
|
||||
|
||||
// request body should include nlq profile
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.profile_id).to.eq("nlq");
|
||||
});
|
||||
|
||||
it("should not be available in OSS", () => {
|
||||
cy.visit("/");
|
||||
// when we receive a navigate_to, we should be taken to a question
|
||||
cy.url().should("include", "/question#");
|
||||
cy.findByTestId("qb-header").should("contain", "Orders");
|
||||
});
|
||||
|
||||
// "AI exploration" option should not appear in new button
|
||||
H.newButton().click();
|
||||
H.popover().findByText("AI exploration").should("not.exist");
|
||||
it("should support clicking suggested prompts", () => {
|
||||
// mock suggested prompts
|
||||
cy.intercept("GET", "/api/metabot/metabot/*/prompt-suggestions*", {
|
||||
prompts: [{ prompt: "Show me all orders" }],
|
||||
});
|
||||
|
||||
// visiting /question/ask should redirect to an empty notebook query
|
||||
cy.visit("/question/ask");
|
||||
cy.url().should("include", "/question#");
|
||||
// visit AI exploration page
|
||||
cy.visit("/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// click suggested prompt
|
||||
const questionHash = H.adhocQuestionHash(allOrdersQuestion);
|
||||
H.mockMetabotResponse({
|
||||
body: mockNavigateToResponse(`/question#${questionHash}`),
|
||||
});
|
||||
cy.get("main").findByText("Show me all orders").click();
|
||||
|
||||
// should be taken to a question
|
||||
cy.wait("@metabotAgent");
|
||||
cy.url().should("include", "/question#");
|
||||
cy.findByTestId("qb-header").should("contain", "Orders");
|
||||
});
|
||||
|
||||
it("should handle errors", () => {
|
||||
// visit AI exploration page
|
||||
cy.visit("/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// mock the agent request to fail
|
||||
H.mockMetabotResponse({ statusCode: 500 });
|
||||
|
||||
// send a prompt
|
||||
metabotPromptInput().type("Show me all orders");
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
|
||||
// should show error
|
||||
cy.get("main")
|
||||
.findByText("Something went wrong. Please try again.")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should handle getting no navigate_to", () => {
|
||||
// visit AI exploration page
|
||||
cy.visit("/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// mock a response without a navigate_to data part
|
||||
H.mockMetabotResponse({
|
||||
body: mockTextOnlyResponse("I need more information to help you."),
|
||||
});
|
||||
|
||||
// send a prompt
|
||||
metabotPromptInput().type("Show me something");
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
cy.wait("@metabotAgent");
|
||||
|
||||
// should be taken to /question/notebook with the sidebar open
|
||||
cy.url().should("include", "/question/notebook");
|
||||
H.assertChatVisibility("visible");
|
||||
});
|
||||
|
||||
it("should not reuse the nlq profile after falling back to notebook chat", () => {
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming", (req) => {
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: mockTextOnlyResponse("ok"),
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}).as("metabotAgent");
|
||||
|
||||
cy.visit("/");
|
||||
H.newButton("AI exploration").click();
|
||||
cy.url().should("include", "/question/ask");
|
||||
|
||||
metabotPromptInput().type("Show me all orders");
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.profile_id).to.eq("nlq");
|
||||
});
|
||||
|
||||
cy.url().should("include", "/question/notebook");
|
||||
H.assertChatVisibility("visible");
|
||||
|
||||
H.sendMetabotMessage("Try again");
|
||||
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.profile_id).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("EE", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
|
||||
it("should cancel requests if the user leaves the page", () => {
|
||||
// visit AI exploration page
|
||||
cy.visit("/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// send a prompt with a delayed response
|
||||
H.mockMetabotResponse({
|
||||
body: mockTextOnlyResponse("This should be canceled"),
|
||||
delay: 2000,
|
||||
});
|
||||
metabotPromptInput().type("Show me something");
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
|
||||
it("should redirect to notebook when metabot-enabled? is false", () => {
|
||||
H.updateSetting("metabot-enabled?", false);
|
||||
// click on the logo in the app bar to leave the page
|
||||
cy.findByTestId("main-logo-link").click();
|
||||
|
||||
cy.log(
|
||||
"visiting '/question/ask' should redirect to notebook when metabot is disabled",
|
||||
);
|
||||
cy.visit("/question/ask");
|
||||
cy.url().should("include", "/question#");
|
||||
cy.findByTestId("metabot-send-message").should("not.exist");
|
||||
});
|
||||
|
||||
it("should not show AI exploration in new button when metabot is disabled", () => {
|
||||
H.updateSetting("metabot-enabled?", false);
|
||||
cy.visit("/");
|
||||
|
||||
cy.log("'AI exploration' option should not appear in new button");
|
||||
H.newButton().click();
|
||||
H.popover().findByText("AI exploration").should("not.exist");
|
||||
});
|
||||
|
||||
it("should be able to successfully generate a notebook query", () => {
|
||||
// visit home page
|
||||
cy.visit("/");
|
||||
|
||||
// go to new button and click "AI exploration"
|
||||
H.newButton("AI exploration").click();
|
||||
|
||||
// should show page
|
||||
cy.url().should("include", "/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// should have disabled send button
|
||||
cy.findByTestId("metabot-send-message").should("be.disabled");
|
||||
|
||||
// should be able to type into the input
|
||||
metabotPromptInput().type("Show me all orders");
|
||||
|
||||
// should be able to send prompt
|
||||
const questionHash = H.adhocQuestionHash(allOrdersQuestion);
|
||||
H.mockMetabotResponse({
|
||||
body: mockNavigateToResponse(`/question#${questionHash}`),
|
||||
delay: 100,
|
||||
});
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
|
||||
// should see loading state, send button should be disabled
|
||||
cy.findByTestId("metabot-send-message").should("be.disabled");
|
||||
cy.findByTestId("metabot-send-message").should(
|
||||
"have.attr",
|
||||
"data-loading",
|
||||
"true",
|
||||
);
|
||||
|
||||
// request body should include nlq profile
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.profile_id).to.eq("nlq");
|
||||
});
|
||||
|
||||
// when we receive a navigate_to, we should be taken to a question
|
||||
cy.url().should("include", "/question#");
|
||||
cy.findByTestId("qb-header").should("contain", "Orders");
|
||||
});
|
||||
|
||||
it("should support clicking suggested prompts", () => {
|
||||
// mock suggested prompts
|
||||
cy.intercept("GET", "/api/metabot/metabot/*/prompt-suggestions*", {
|
||||
prompts: [{ prompt: "Show me all orders" }],
|
||||
});
|
||||
|
||||
// visit AI exploration page
|
||||
cy.visit("/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// click suggested prompt
|
||||
const questionHash = H.adhocQuestionHash(allOrdersQuestion);
|
||||
H.mockMetabotResponse({
|
||||
body: mockNavigateToResponse(`/question#${questionHash}`),
|
||||
});
|
||||
cy.get("main").findByText("Show me all orders").click();
|
||||
|
||||
// should be taken to a question
|
||||
cy.wait("@metabotAgent");
|
||||
cy.url().should("include", "/question#");
|
||||
cy.findByTestId("qb-header").should("contain", "Orders");
|
||||
});
|
||||
|
||||
it("should handle errors", () => {
|
||||
// visit AI exploration page
|
||||
cy.visit("/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// mock the agent request to fail
|
||||
H.mockMetabotResponse({ statusCode: 500 });
|
||||
|
||||
// send a prompt
|
||||
metabotPromptInput().type("Show me all orders");
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
|
||||
// should show error
|
||||
cy.get("main")
|
||||
.findByText("Something went wrong. Please try again.")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should handle getting no navigate_to", () => {
|
||||
// visit AI exploration page
|
||||
cy.visit("/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// mock a response without a navigate_to data part
|
||||
H.mockMetabotResponse({
|
||||
body: mockTextOnlyResponse("I need more information to help you."),
|
||||
});
|
||||
|
||||
// send a prompt
|
||||
metabotPromptInput().type("Show me something");
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
cy.wait("@metabotAgent");
|
||||
|
||||
// should be taken to /question/notebook with the sidebar open
|
||||
cy.url().should("include", "/question/notebook");
|
||||
H.assertChatVisibility("visible");
|
||||
});
|
||||
|
||||
it("should not reuse the nlq profile after falling back to notebook chat", () => {
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming", (req) => {
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: mockTextOnlyResponse("ok"),
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}).as("metabotAgent");
|
||||
|
||||
cy.visit("/");
|
||||
H.newButton("AI exploration").click();
|
||||
cy.url().should("include", "/question/ask");
|
||||
|
||||
metabotPromptInput().type("Show me all orders");
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.profile_id).to.eq("nlq");
|
||||
});
|
||||
|
||||
cy.url().should("include", "/question/notebook");
|
||||
H.assertChatVisibility("visible");
|
||||
|
||||
H.sendMetabotMessage("Try again");
|
||||
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.profile_id).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel requests if the user leaves the page", () => {
|
||||
// visit AI exploration page
|
||||
cy.visit("/question/ask");
|
||||
cy.findByTestId("metabot-send-message").should("be.visible");
|
||||
|
||||
// send a prompt with a delayed response
|
||||
H.mockMetabotResponse({
|
||||
body: mockTextOnlyResponse("This should be canceled"),
|
||||
delay: 2000,
|
||||
});
|
||||
metabotPromptInput().type("Show me something");
|
||||
cy.findByTestId("metabot-send-message").click();
|
||||
|
||||
// click on the logo in the app bar to leave the page
|
||||
cy.findByTestId("main-logo-link").click();
|
||||
|
||||
// check that the agent request was canceled
|
||||
cy.get("@metabotAgent").its("state").should("eq", "Errored");
|
||||
});
|
||||
// check that the agent request was canceled
|
||||
cy.get("@metabotAgent").its("state").should("eq", "Errored");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,130 +9,112 @@ describe("Metabot UI", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
|
||||
cy.intercept("GET", "/api/automagic-dashboards/database/*/candidates").as(
|
||||
"xrayCandidates",
|
||||
);
|
||||
});
|
||||
|
||||
describe("OSS", { tags: "@OSS" }, () => {
|
||||
describe("scroll management", () => {
|
||||
beforeEach(() => {
|
||||
cy.visit("/");
|
||||
cy.wait("@xrayCandidates");
|
||||
});
|
||||
|
||||
it("should not be available in OSS", () => {
|
||||
H.openMetabotViaShortcutKey(false);
|
||||
H.assertChatVisibility("not.visible");
|
||||
cy.findByLabelText("Navigation bar").within(() => {
|
||||
cy.findByText("New").click();
|
||||
});
|
||||
H.popover().findByText("Metabot request").should("not.exist");
|
||||
H.assertChatVisibility("not.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EE", () => {
|
||||
beforeEach(() => {
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.visit("/");
|
||||
cy.wait("@xrayCandidates");
|
||||
it("should not show filler element if there are not messages", () => {
|
||||
H.openMetabotViaSearchButton();
|
||||
H.chatMessages().should("not.exist");
|
||||
cy.findByTestId("metabot-message-filler").should("not.exist");
|
||||
});
|
||||
|
||||
describe("scroll management", () => {
|
||||
it("should not show filler element if there are not messages", () => {
|
||||
H.openMetabotViaSearchButton();
|
||||
H.chatMessages().should("not.exist");
|
||||
cy.findByTestId("metabot-message-filler").should("not.exist");
|
||||
it("should correctly size the filler element to take remaining space if messages aren't scrollable", () => {
|
||||
H.openMetabotViaSearchButton();
|
||||
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: whoIsYourFavoriteResponse,
|
||||
});
|
||||
|
||||
it("should correctly size the filler element to take remaining space if messages aren't scrollable", () => {
|
||||
H.openMetabotViaSearchButton();
|
||||
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: whoIsYourFavoriteResponse,
|
||||
H.sendMetabotMessage("Who is your favorite?");
|
||||
cy.findByTestId("metabot-chat-inner-messages")
|
||||
.invoke("innerHeight")
|
||||
.then((containerHeight) => {
|
||||
cy.findByTestId("metabot-chat-inner-messages")
|
||||
.children()
|
||||
.then(($children) => {
|
||||
const contentHeight = Array.from($children).reduce(
|
||||
(sum, child) => {
|
||||
return sum + child.clientHeight;
|
||||
},
|
||||
0,
|
||||
);
|
||||
expect(containerHeight).not.to.equal(undefined);
|
||||
// we can get some subpixel differences, this isn't a big deal
|
||||
expect(contentHeight).to.be.closeTo(containerHeight ?? 0, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
H.sendMetabotMessage("Who is your favorite?");
|
||||
cy.findByTestId("metabot-chat-inner-messages")
|
||||
.invoke("innerHeight")
|
||||
.then((containerHeight) => {
|
||||
cy.findByTestId("metabot-chat-inner-messages")
|
||||
.children()
|
||||
.then(($children) => {
|
||||
const contentHeight = Array.from($children).reduce(
|
||||
(sum, child) => {
|
||||
return sum + child.clientHeight;
|
||||
},
|
||||
0,
|
||||
);
|
||||
expect(containerHeight).not.to.equal(undefined);
|
||||
// we can get some subpixel differences, this isn't a big deal
|
||||
expect(contentHeight).to.be.closeTo(containerHeight ?? 0, 1);
|
||||
});
|
||||
});
|
||||
it("should resize filler element and auto-scroll to new prompt on subsequent messages", () => {
|
||||
H.openMetabotViaSearchButton();
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: whoIsYourFavoriteResponse,
|
||||
});
|
||||
H.sendMetabotMessage("Who is your favorite?");
|
||||
|
||||
cy.log("test on message shorter than prompt");
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: `0:"${loremIpsum.repeat(5)}"
|
||||
d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`,
|
||||
});
|
||||
H.sendMetabotMessage("You really mean that?");
|
||||
cy.log("scroll new prompt to top of the scroll area");
|
||||
cy.findByTestId("metabot-chat-inner-messages")
|
||||
.findByText("You really mean that?")
|
||||
.invoke("scrollTop")
|
||||
.then((scrollTop) => expect(scrollTop).to.equal(0));
|
||||
|
||||
cy.log(
|
||||
"if the response is shorter than the scroll area, filler should have height",
|
||||
);
|
||||
cy.findByTestId("metabot-message-filler").then(($el) => {
|
||||
expect($el[0].clientHeight).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("should resize filler element and auto-scroll to new prompt on subsequent messages", () => {
|
||||
H.openMetabotViaSearchButton();
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: whoIsYourFavoriteResponse,
|
||||
});
|
||||
H.sendMetabotMessage("Who is your favorite?");
|
||||
|
||||
cy.log("test on message shorter than prompt");
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: `0:"${loremIpsum.repeat(5)}"
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: `0:"${loremIpsum.repeat(50)}"
|
||||
d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`,
|
||||
});
|
||||
H.sendMetabotMessage("You really mean that?");
|
||||
cy.log("scroll new prompt to top of the scroll area");
|
||||
cy.findByTestId("metabot-chat-inner-messages")
|
||||
.findByText("You really mean that?")
|
||||
.invoke("scrollTop")
|
||||
.then((scrollTop) => expect(scrollTop).to.equal(0));
|
||||
|
||||
cy.log(
|
||||
"if the response is shorter than the scroll area, filler should have height",
|
||||
);
|
||||
cy.findByTestId("metabot-message-filler").then(($el) => {
|
||||
expect($el[0].clientHeight).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: `0:"${loremIpsum.repeat(50)}"
|
||||
d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`,
|
||||
});
|
||||
H.sendMetabotMessage("Keep going...");
|
||||
|
||||
cy.log(
|
||||
"if the response is longer than the scroll area the filler height should be zero",
|
||||
);
|
||||
cy.findByTestId("metabot-message-filler").then(($el) => {
|
||||
expect($el[0].clientHeight).to.equal(0);
|
||||
});
|
||||
});
|
||||
H.sendMetabotMessage("Keep going...");
|
||||
|
||||
it("should open metabot to the bottom of the conversation when reopened with message history", () => {
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: `0:"${loremIpsum.repeat(5)}"
|
||||
cy.log(
|
||||
"if the response is longer than the scroll area the filler height should be zero",
|
||||
);
|
||||
cy.findByTestId("metabot-message-filler").then(($el) => {
|
||||
expect($el[0].clientHeight).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should open metabot to the bottom of the conversation when reopened with message history", () => {
|
||||
H.mockMetabotResponse({
|
||||
statusCode: 200,
|
||||
body: `0:"${loremIpsum.repeat(5)}"
|
||||
d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`,
|
||||
});
|
||||
H.openMetabotViaSearchButton();
|
||||
H.sendMetabotMessage("Who is your favorite?");
|
||||
});
|
||||
H.openMetabotViaSearchButton();
|
||||
H.sendMetabotMessage("Who is your favorite?");
|
||||
|
||||
H.closeMetabotViaCloseButton();
|
||||
H.openMetabotViaSearchButton();
|
||||
cy.findByTestId("metabot-chat-inner-messages").then(($el) => {
|
||||
const el = $el[0];
|
||||
const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight;
|
||||
expect(isAtBottom).to.be.true;
|
||||
});
|
||||
H.closeMetabotViaCloseButton();
|
||||
H.openMetabotViaSearchButton();
|
||||
cy.findByTestId("metabot-chat-inner-messages").then(($el) => {
|
||||
const el = $el[0];
|
||||
const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight;
|
||||
expect(isAtBottom).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -144,6 +126,7 @@ d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`,
|
||||
cy.signInAsAdmin();
|
||||
H.enableTracking();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -240,6 +223,7 @@ describe("Metabot in full-app embedding", () => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
});
|
||||
|
||||
it("should show the metabot button when embedded-metabot-enabled? is true", () => {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
const { ORDERS_ID, PEOPLE_ID } = SAMPLE_DATABASE;
|
||||
|
||||
const toggleInlineSQLPrompt = () => {
|
||||
H.NativeEditor.get().focus();
|
||||
cy.wait(250);
|
||||
@@ -20,348 +16,219 @@ const acceptButton = () => cy.findByTestId("accept-proposed-changes-button");
|
||||
const rejectButton = () => cy.findByTestId("reject-proposed-changes-button");
|
||||
const generatingLoader = () => cy.findByTestId("metabot-inline-sql-generating");
|
||||
|
||||
// Table bar helpers
|
||||
const tableBar = () => cy.findByTestId("metabot-table-input");
|
||||
const tableBarInput = () => tableBar().find("input");
|
||||
const tablePill = (name: string) => tableBar().contains(name);
|
||||
|
||||
describe("Native SQL generation", () => {
|
||||
describe("OSS", { tags: "@OSS" }, () => {
|
||||
it("should not be available when metabot is not configured", () => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("not.exist");
|
||||
});
|
||||
|
||||
describe("single db", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should not be available in OSS", () => {
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("not.exist");
|
||||
});
|
||||
|
||||
it("should show table bar and support full generation workflow", () => {
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.request("PUT", "/api/setting/llm-anthropic-api-key", {
|
||||
value: "sk-ant-api03-test-token",
|
||||
});
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
|
||||
});
|
||||
|
||||
const finalSQL =
|
||||
"SELECT o.*, p.*\nFROM public.orders o\nJOIN public.people p ON o.user_id = p.id";
|
||||
cy.intercept("POST", "/api/llm/generate-sql", {
|
||||
statusCode: 200,
|
||||
delay: 100,
|
||||
body: {
|
||||
sql: finalSQL,
|
||||
referenced_entities: [
|
||||
{
|
||||
id: ORDERS_ID,
|
||||
model: "table",
|
||||
name: "ORDERS",
|
||||
display_name: "Orders",
|
||||
},
|
||||
{
|
||||
id: PEOPLE_ID,
|
||||
model: "table",
|
||||
name: "PEOPLE",
|
||||
display_name: "People",
|
||||
},
|
||||
],
|
||||
},
|
||||
}).as("generateSql");
|
||||
|
||||
cy.log("feature is available when LLM is configured");
|
||||
H.startNewNativeQuestion({ query: "SELECT * FROM ORDERS" });
|
||||
it("should be able to successfully generate sql", () => {
|
||||
H.startNewNativeQuestion({ query: "SELECT 1" });
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
generateButton().should("be.disabled");
|
||||
tableBar().should("be.visible");
|
||||
|
||||
// Wait for potentially cold parser to warmup.
|
||||
// Details in src/sql_parsing/pool.clj
|
||||
cy.wait(10000);
|
||||
|
||||
cy.log("extract-tables populates table pills from existing query");
|
||||
tablePill("Orders").should("be.visible");
|
||||
|
||||
cy.log("typing in table bar input renders search results");
|
||||
tableBarInput().click();
|
||||
cy.realType("people", { pressDelay: 10 });
|
||||
cy.findByRole("option", { name: /People/i }).should("be.visible");
|
||||
|
||||
cy.log("selecting People from dropdown adds it to the list");
|
||||
cy.findByRole("option", { name: /People/i }).click();
|
||||
tablePill("People").should("be.visible");
|
||||
tablePill("Orders").should("be.visible");
|
||||
|
||||
cy.log("selecting Reviews from dropdown adds it to the list");
|
||||
tableBarInput().click();
|
||||
cy.realType("reviews", { pressDelay: 10 });
|
||||
cy.findByRole("option", { name: /Reviews/i }).should("be.visible");
|
||||
cy.findByRole("option", { name: /Reviews/i }).click();
|
||||
tablePill("Reviews").should("be.visible");
|
||||
tablePill("People").should("be.visible");
|
||||
tablePill("Orders").should("be.visible");
|
||||
|
||||
cy.log("can remove table pill by clicking and pressing backspace");
|
||||
tablePill("Reviews").click();
|
||||
cy.realPress("Backspace");
|
||||
tablePill("Reviews").should("not.exist");
|
||||
tablePill("People").should("be.visible");
|
||||
tablePill("Orders").should("be.visible");
|
||||
|
||||
cy.log("generate button enables after typing prompt");
|
||||
inlinePromptInput().click();
|
||||
cy.realType("show me orders", { pressDelay: 10 });
|
||||
cy.realType("select all users", { pressDelay: 10 });
|
||||
generateButton().should("be.enabled");
|
||||
|
||||
cy.log("clicking generate shows loading state and sends correct request");
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT * FROM users"),
|
||||
delay: 100,
|
||||
});
|
||||
generateButton().click();
|
||||
generatingLoader().should("exist");
|
||||
|
||||
cy.wait("@generateSql").then(({ request }) => {
|
||||
expect(request.body.prompt).to.eq("show me orders");
|
||||
expect(request.body.database_id).to.eq(1);
|
||||
expect(request.body.referenced_entities).to.have.length(2);
|
||||
const tableIds = request.body.referenced_entities.map(
|
||||
(e: { id: number }) => e.id,
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
const codeEditor = request.body.context.user_is_viewing.find(
|
||||
(ctx: { type: string }) => ctx.type === "code_editor",
|
||||
);
|
||||
expect(tableIds).to.include(ORDERS_ID);
|
||||
expect(tableIds).to.include(PEOPLE_ID);
|
||||
expect(codeEditor).to.exist;
|
||||
expect(codeEditor.buffers).to.have.length(1);
|
||||
expect(codeEditor.buffers[0].source.language).to.eq("sql");
|
||||
expect(codeEditor.buffers[0].source.database_id).to.eq(1); // Sample Database
|
||||
});
|
||||
|
||||
cy.log("generation completes and shows accept/reject buttons");
|
||||
// should auto-close input and show diff with accept/reject buttons
|
||||
inlinePrompt().should("not.exist");
|
||||
acceptButton().should("be.visible");
|
||||
rejectButton().should("be.visible");
|
||||
H.NativeEditor.get().should("contain", "SELECT * FROM ORDERS");
|
||||
H.NativeEditor.get().should("contain", "SELECT 1");
|
||||
H.NativeEditor.get().should("contain", "SELECT * FROM users");
|
||||
|
||||
cy.log("accepting changes updates the editor");
|
||||
// should be able to accept changes
|
||||
acceptButton().click();
|
||||
acceptButton().should("not.exist");
|
||||
rejectButton().should("not.exist");
|
||||
finalSQL.split("\n").forEach((part) => {
|
||||
H.NativeEditor.get().should("contain", part);
|
||||
});
|
||||
H.NativeEditor.get().should("not.contain", "SELECT 1");
|
||||
H.NativeEditor.get().should("contain", "SELECT * FROM users");
|
||||
});
|
||||
|
||||
cy.log("manually editing query to add reviews extracts reviews table");
|
||||
H.NativeEditor.get().click();
|
||||
H.NativeEditor.get().realPress([H.metaKey, "a"]);
|
||||
H.NativeEditor.get().realPress("Backspace");
|
||||
H.NativeEditor.type(
|
||||
"SELECT * FROM ORDERS JOIN REVIEWS ON ORDERS.PRODUCT_ID = REVIEWS.PRODUCT_ID",
|
||||
{ delay: 10 },
|
||||
);
|
||||
it("should be able to correctly control the input", () => {
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
|
||||
// open / close the editor via key press
|
||||
inlinePrompt().should("not.exist");
|
||||
toggleInlineSQLPrompt();
|
||||
tablePill("Orders").should("be.visible");
|
||||
tablePill("Reviews").should("be.visible");
|
||||
inlinePrompt().should("be.visible");
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("not.exist");
|
||||
|
||||
// cancel button should close
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
cancelButton().click();
|
||||
inlinePrompt().should("not.exist");
|
||||
|
||||
// cancel button should cancel inflight request
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("select all users", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT * FROM users"),
|
||||
delay: 1000,
|
||||
});
|
||||
generateButton().click();
|
||||
generatingLoader().should("exist");
|
||||
cancelButton().click();
|
||||
cy.get("@metabotAgent").its("state").should("eq", "Errored");
|
||||
inlinePrompt().should("not.exist");
|
||||
acceptButton().should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ee", () => {
|
||||
describe("single db", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
|
||||
});
|
||||
|
||||
it("should be able to successfully generate sql", () => {
|
||||
cy.intercept("POST", "/api/llm/extract-tables").as("extractTables");
|
||||
|
||||
H.startNewNativeQuestion({ query: "SELECT 1" });
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
generateButton().should("be.disabled");
|
||||
|
||||
cy.log("table bar should not be shown for EE");
|
||||
tableBar().should("not.exist");
|
||||
|
||||
inlinePromptInput().click();
|
||||
cy.realType("select all users", { pressDelay: 10 });
|
||||
generateButton().should("be.enabled");
|
||||
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT * FROM users"),
|
||||
delay: 100,
|
||||
});
|
||||
generateButton().click();
|
||||
generatingLoader().should("exist");
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
const codeEditor = request.body.context.user_is_viewing.find(
|
||||
(ctx: { type: string }) => ctx.type === "code_editor",
|
||||
);
|
||||
expect(codeEditor).to.exist;
|
||||
expect(codeEditor.buffers).to.have.length(1);
|
||||
expect(codeEditor.buffers[0].source.language).to.eq("sql");
|
||||
expect(codeEditor.buffers[0].source.database_id).to.eq(1); // Sample Database
|
||||
});
|
||||
|
||||
// should auto-close input and show diff with accept/reject buttons
|
||||
inlinePrompt().should("not.exist");
|
||||
acceptButton().should("be.visible");
|
||||
rejectButton().should("be.visible");
|
||||
H.NativeEditor.get().should("contain", "SELECT 1");
|
||||
H.NativeEditor.get().should("contain", "SELECT * FROM users");
|
||||
|
||||
// should be able to accept changes
|
||||
acceptButton().click();
|
||||
acceptButton().should("not.exist");
|
||||
rejectButton().should("not.exist");
|
||||
H.NativeEditor.get().should("not.contain", "SELECT 1");
|
||||
H.NativeEditor.get().should("contain", "SELECT * FROM users");
|
||||
|
||||
cy.log("extract-tables should not be called for EE");
|
||||
cy.get("@extractTables.all").should("have.length", 0);
|
||||
});
|
||||
|
||||
it("should be able to correctly control the input", () => {
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
|
||||
// open / close the editor via key press
|
||||
inlinePrompt().should("not.exist");
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("not.exist");
|
||||
|
||||
// cancel button should close
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
cancelButton().click();
|
||||
inlinePrompt().should("not.exist");
|
||||
|
||||
// cancel button should cancel inflight request
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("select all users", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT * FROM users"),
|
||||
delay: 1000,
|
||||
});
|
||||
generateButton().click();
|
||||
generatingLoader().should("exist");
|
||||
cancelButton().click();
|
||||
cy.get("@metabotAgent").its("state").should("eq", "Errored");
|
||||
inlinePrompt().should("not.exist");
|
||||
acceptButton().should("not.exist");
|
||||
describe("multi-db", () => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.request("PUT", "/api/setting/llm-anthropic-api-key", {
|
||||
value: "sk-ant-api03-test-token",
|
||||
});
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
|
||||
});
|
||||
|
||||
describe("multi-db", () => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
|
||||
it("should manage conversation state correctly", () => {
|
||||
H.startNewNativeQuestion({ query: "SELECT 1" });
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
|
||||
// open input, send a prompt, get suggestion back
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("select all users", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT * FROM users"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent");
|
||||
acceptButton().should("be.visible");
|
||||
|
||||
// reject the change and open input again
|
||||
rejectButton().click();
|
||||
acceptButton().should("not.exist");
|
||||
H.NativeEditor.get().should("contain", "SELECT 1");
|
||||
H.NativeEditor.get().should("not.contain", "SELECT * FROM users");
|
||||
H.NativeEditor.focus();
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
|
||||
// send another message, history should contain rejection info
|
||||
inlinePromptInput().click();
|
||||
cy.realType("try again", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT id FROM users"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.history).to.have.length.greaterThan(0);
|
||||
expect(request.body.message).to.include(
|
||||
"User rejected the following suggestion:\n\nSELECT * FROM users",
|
||||
);
|
||||
});
|
||||
acceptButton().should("be.visible");
|
||||
|
||||
// change the selected database, should close the input
|
||||
rejectButton().click();
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
H.NativeEditor.selectDataSource("QA Postgres12");
|
||||
inlinePrompt().should("not.exist");
|
||||
|
||||
// open again, send a prompt, req.body.history should be empty
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("select something", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT 123"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.history).to.have.length(0);
|
||||
});
|
||||
|
||||
it("should manage conversation state correctly", () => {
|
||||
H.startNewNativeQuestion({ query: "SELECT 1" });
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
// should get a valid response back
|
||||
acceptButton().should("be.visible");
|
||||
H.NativeEditor.get().should("contain", "SELECT 123");
|
||||
|
||||
// open input, send a prompt, get suggestion back
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("select all users", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT * FROM users"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent");
|
||||
acceptButton().should("be.visible");
|
||||
|
||||
// reject the change and open input again
|
||||
rejectButton().click();
|
||||
acceptButton().should("not.exist");
|
||||
H.NativeEditor.get().should("contain", "SELECT 1");
|
||||
H.NativeEditor.get().should("not.contain", "SELECT * FROM users");
|
||||
H.NativeEditor.focus();
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
|
||||
// send another message, history should contain rejection info
|
||||
inlinePromptInput().click();
|
||||
cy.realType("try again", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT id FROM users"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.history).to.have.length.greaterThan(0);
|
||||
expect(request.body.message).to.include(
|
||||
"User rejected the following suggestion:\n\nSELECT * FROM users",
|
||||
);
|
||||
});
|
||||
acceptButton().should("be.visible");
|
||||
|
||||
// change the selected database, should close the input
|
||||
rejectButton().click();
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePrompt().should("be.visible");
|
||||
H.NativeEditor.selectDataSource("QA Postgres12");
|
||||
inlinePrompt().should("not.exist");
|
||||
|
||||
// open again, send a prompt, req.body.history should be empty
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("select something", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT 123"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.history).to.have.length(0);
|
||||
});
|
||||
|
||||
// should get a valid response back
|
||||
acceptButton().should("be.visible");
|
||||
H.NativeEditor.get().should("contain", "SELECT 123");
|
||||
|
||||
// leave the page, go to new SQL page and send a new prompt
|
||||
acceptButton().click();
|
||||
cy.visit("/");
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("new prompt", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT 456"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.history).to.have.length(0);
|
||||
});
|
||||
acceptButton().should("be.visible");
|
||||
|
||||
// manually editing the editor should dismiss suggestion buttons
|
||||
H.NativeEditor.get().click();
|
||||
H.NativeEditor.get().realPress([H.metaKey, "a"]);
|
||||
H.NativeEditor.get().realPress("Backspace");
|
||||
acceptButton().should("not.exist");
|
||||
rejectButton().should("not.exist");
|
||||
// leave the page, go to new SQL page and send a new prompt
|
||||
acceptButton().click();
|
||||
cy.visit("/");
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("new prompt", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockCodeEditResponse("SELECT 456"),
|
||||
});
|
||||
|
||||
it("should show error if no code_edit is received", () => {
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("do something", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockTextOnlyResponse("I can help with that!"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent");
|
||||
|
||||
errorMessage().should("be.visible");
|
||||
acceptButton().should("not.exist");
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent").then(({ request }) => {
|
||||
expect(request.body.history).to.have.length(0);
|
||||
});
|
||||
acceptButton().should("be.visible");
|
||||
|
||||
// manually editing the editor should dismiss suggestion buttons
|
||||
H.NativeEditor.get().click();
|
||||
H.NativeEditor.get().realPress([H.metaKey, "a"]);
|
||||
H.NativeEditor.get().realPress("Backspace");
|
||||
acceptButton().should("not.exist");
|
||||
rejectButton().should("not.exist");
|
||||
});
|
||||
|
||||
it("should show error if no code_edit is received", () => {
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.get().should("be.visible");
|
||||
|
||||
toggleInlineSQLPrompt();
|
||||
inlinePromptInput().click();
|
||||
cy.realType("do something", { pressDelay: 10 });
|
||||
H.mockMetabotResponse({
|
||||
body: mockTextOnlyResponse("I can help with that!"),
|
||||
});
|
||||
generateButton().click();
|
||||
cy.wait("@metabotAgent");
|
||||
|
||||
errorMessage().should("be.visible");
|
||||
acceptButton().should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ describe(
|
||||
H.resetSnowplow();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.updateSetting("llm-anthropic-api-key", "sk-ant-test-key");
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: SOURCE_TABLE });
|
||||
|
||||
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("scenarios > browse > metrics", () => {
|
||||
).should("be.visible");
|
||||
cy.findByText("Create metric").should("be.visible").click();
|
||||
});
|
||||
cy.location("pathname").should("eq", "/metric/query");
|
||||
cy.location("pathname").should("eq", "/metric/new");
|
||||
});
|
||||
|
||||
it("should not show the create metric button if the user does not have data access", () => {
|
||||
@@ -135,22 +135,24 @@ describe("scenarios > browse > metrics", () => {
|
||||
cy.findByTestId("browse-metrics-header")
|
||||
.findByLabelText("Create a new metric")
|
||||
.click();
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("People").click();
|
||||
});
|
||||
cy.findByTestId("edit-bar")
|
||||
.should("contain", "New metric")
|
||||
.button("Save")
|
||||
.click();
|
||||
H.modal()
|
||||
.should("contain", "Save metric")
|
||||
.and("contain", H.getPersonalCollectionName(USERS["nocollection"]))
|
||||
.button("Save")
|
||||
.click();
|
||||
H.MetricPage.saveButton().click();
|
||||
H.modal().within(() => {
|
||||
cy.findByPlaceholderText("What is the name of your metric?").type(
|
||||
"My metric",
|
||||
);
|
||||
cy.findByText("Save your metric");
|
||||
cy.findByText(H.getPersonalCollectionName(USERS["nocollection"]));
|
||||
cy.button("Save").click();
|
||||
});
|
||||
|
||||
cy.wait("@createMetric");
|
||||
cy.location("pathname").should("match", /^\/metric\/\d+-.*$/);
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
cy.location("pathname").should("match", /^\/metric\/\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,9 +171,8 @@ describe("scenarios > browse > metrics", () => {
|
||||
createMetrics([ORDERS_SCALAR_METRIC]);
|
||||
cy.visit("/browse/metrics");
|
||||
findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible").click();
|
||||
cy.location("pathname").should("match", /^\/explore/);
|
||||
H.MetricsViewer.openMetricHomePage("Count of orders");
|
||||
cy.location("pathname").should("match", /^\/metric\/\d+-.*$/);
|
||||
cy.location("pathname").should("match", /^\/metric\//);
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
});
|
||||
|
||||
it("should navigate to that collection when clicking a collection title", () => {
|
||||
@@ -200,7 +201,7 @@ describe("scenarios > browse > metrics", () => {
|
||||
cy.get("@open").should("have.been.calledOnce");
|
||||
cy.get("@open").should(
|
||||
"have.been.calledWithMatch",
|
||||
/^\/explore/,
|
||||
/^\/metric\//,
|
||||
"_blank",
|
||||
);
|
||||
|
||||
@@ -447,6 +448,7 @@ describe("scenarios > browse > metrics", () => {
|
||||
cy.visit("/browse/metrics");
|
||||
verifyMetric(ORDERS_SCALAR_METRIC);
|
||||
|
||||
findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible");
|
||||
cy.findByRole("switch", { name: /show.*verified.*metrics/i }).should(
|
||||
"have.attr",
|
||||
"aria-selected",
|
||||
@@ -495,12 +497,11 @@ function shouldNotHaveBookmark(name: string) {
|
||||
|
||||
function verifyMetric(metric: StructuredQuestionDetailsWithName) {
|
||||
metricsTable().findByText(metric.name).should("be.visible").click();
|
||||
H.MetricsViewer.openMetricHomePage(metric.name);
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
|
||||
cy.findByLabelText("Move, trash, and more…").click();
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Verify this metric").click();
|
||||
|
||||
H.openNavigationSidebar();
|
||||
cy.icon("verified").should("be.visible");
|
||||
H.navigationSidebar()
|
||||
.findByRole("listitem", { name: "Browse metrics" })
|
||||
.click();
|
||||
@@ -508,12 +509,11 @@ function verifyMetric(metric: StructuredQuestionDetailsWithName) {
|
||||
|
||||
function unverifyMetric(metric: StructuredQuestionDetailsWithName) {
|
||||
metricsTable().findByText(metric.name).should("be.visible").click();
|
||||
H.MetricsViewer.openMetricHomePage(metric.name);
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
|
||||
cy.findByLabelText("Move, trash, and more…").click();
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Remove verification").click();
|
||||
|
||||
H.openNavigationSidebar();
|
||||
cy.icon("verified").should("not.exist");
|
||||
H.navigationSidebar()
|
||||
.findByRole("listitem", { name: "Browse metrics" })
|
||||
.click();
|
||||
|
||||
382
e2e/test/scenarios/metrics/metric-page.cy.spec.ts
Normal file
382
e2e/test/scenarios/metrics/metric-page.cy.spec.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
const { H } = cy;
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import {
|
||||
TRUSTED_ORDERS_METRIC,
|
||||
createLibraryWithItems,
|
||||
} from "e2e/support/test-library-data";
|
||||
import type { Card } from "metabase-types/api";
|
||||
|
||||
const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE;
|
||||
|
||||
const ORDERS_SCALAR_METRIC = {
|
||||
name: "Orders count",
|
||||
description: "Total number of orders",
|
||||
type: "metric" as const,
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
display: "scalar" as const,
|
||||
};
|
||||
|
||||
const ORDERS_TIMESERIES_METRIC = {
|
||||
name: "Orders over time",
|
||||
description: "Count of orders broken down by month",
|
||||
type: "metric" as const,
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
breakout: [
|
||||
[
|
||||
"field",
|
||||
ORDERS.CREATED_AT,
|
||||
{ "base-type": "type/DateTime", "temporal-unit": "month" },
|
||||
],
|
||||
],
|
||||
},
|
||||
display: "line" as const,
|
||||
};
|
||||
|
||||
describe("scenarios > metrics > metric page", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should display scalar metric, edit name and description, explore link, and more menu actions", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
cy.intercept("POST", "/api/card").as("createCard");
|
||||
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
H.visitMetric(metric.id);
|
||||
});
|
||||
|
||||
cy.log("about page with description sidebar");
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
H.MetricPage.aboutPageDescriptionSidebar().within(() => {
|
||||
cy.findByText("Total number of orders").should("be.visible");
|
||||
cy.findByText("Sample Database").should("be.visible");
|
||||
cy.findByText("Source table").should("be.visible");
|
||||
cy.findByText("Orders").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("explore link");
|
||||
H.MetricPage.header()
|
||||
.findByText("Explore")
|
||||
.closest("a")
|
||||
.should("have.attr", "href")
|
||||
.and("include", "/explore");
|
||||
|
||||
cy.log("edit description");
|
||||
H.MetricPage.aboutPageDescriptionSidebar().within(() => {
|
||||
cy.findByText("Total number of orders").click();
|
||||
});
|
||||
cy.focused().clear().type("Updated description").blur();
|
||||
cy.wait("@updateCard");
|
||||
H.MetricPage.aboutPageDescriptionSidebar()
|
||||
.findByText("Updated description")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("edit name inline");
|
||||
H.MetricPage.aboutPage()
|
||||
.findByDisplayValue("Orders count")
|
||||
.clear()
|
||||
.type("Renamed metric{enter}");
|
||||
cy.wait("@updateCard");
|
||||
H.MetricPage.aboutPage()
|
||||
.findByDisplayValue("Renamed metric")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("duplicate via more menu");
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Duplicate").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name")
|
||||
.should("have.value", "Renamed metric - Duplicate")
|
||||
.clear()
|
||||
.type("Renamed metric copy");
|
||||
cy.button("Duplicate").click();
|
||||
});
|
||||
cy.wait("@createCard");
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
});
|
||||
|
||||
it("should open alert channel setup modal from more menu when no channels configured", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
H.visitMetric(metric.id);
|
||||
});
|
||||
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Create an alert").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText(
|
||||
"To get notified when something happens, or to send this chart on a schedule, first set up email, Slack, or a webhook.",
|
||||
).should("be.visible");
|
||||
|
||||
cy.findByText("Set up email")
|
||||
.should("be.visible")
|
||||
.closest("a")
|
||||
.should("have.attr", "href", "/admin/settings/email");
|
||||
cy.findByText("Set up Slack")
|
||||
.should("be.visible")
|
||||
.closest("a")
|
||||
.should("have.attr", "href", "/admin/settings/slack");
|
||||
cy.findByText("Add a webhook")
|
||||
.should("be.visible")
|
||||
.closest("a")
|
||||
.should("have.attr", "href", "/admin/settings/webhooks");
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
"should create an alert with webhook and show Edit alerts after",
|
||||
{ tags: ["@external"] },
|
||||
() => {
|
||||
H.setupNotificationChannel({
|
||||
name: "Foo Hook",
|
||||
description: "This is a hook",
|
||||
});
|
||||
H.setupNotificationChannel({
|
||||
name: "Bar Hook",
|
||||
description: "This is another hook",
|
||||
});
|
||||
cy.setCookie("metabase.SEEN_ALERT_SPLASH", "true");
|
||||
|
||||
cy.intercept("POST", "/api/notification").as("createAlert");
|
||||
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
H.visitMetric(metric.id);
|
||||
});
|
||||
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Create an alert").click();
|
||||
|
||||
H.addNotificationHandlerChannel("Bar Hook");
|
||||
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
|
||||
cy.wait("@createAlert").then(({ response }) => {
|
||||
expect(response?.body?.payload?.send_condition).to.equal("has_result");
|
||||
});
|
||||
|
||||
H.notificationList().findByText("Your alert is all set up.");
|
||||
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Edit alerts").should("be.visible");
|
||||
},
|
||||
);
|
||||
|
||||
it("should display timeseries metric and navigate between tabs", () => {
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC).then(({ body: metric }) => {
|
||||
H.visitMetric(metric.id);
|
||||
});
|
||||
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
H.echartsContainer().should("be.visible");
|
||||
|
||||
H.MetricPage.aboutTab().should("be.visible");
|
||||
H.MetricPage.overviewTab().should("be.visible");
|
||||
H.MetricPage.definitionTab().should("be.visible");
|
||||
H.MetricPage.historyTab().should("be.visible");
|
||||
|
||||
H.MetricPage.definitionTab().click();
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.getNotebookStep("data").findByText("Orders").should("be.visible");
|
||||
|
||||
H.MetricPage.historyTab().click();
|
||||
cy.findAllByTestId("revision-history-event").should("have.length.gte", 1);
|
||||
|
||||
H.MetricPage.aboutTab().click();
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
});
|
||||
|
||||
it("should render dimension charts on the overview tab and show more", () => {
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC).then(({ body: metric }) => {
|
||||
H.visitMetric(metric.id);
|
||||
});
|
||||
|
||||
H.MetricPage.overviewTab().click();
|
||||
H.MetricPage.overviewPage().should("be.visible");
|
||||
|
||||
H.MetricPage.overviewPage().within(() => {
|
||||
cy.findByText("By Created At").should("exist");
|
||||
cy.findByText("By State").should("exist");
|
||||
cy.findByText("By Category").should("exist");
|
||||
cy.findByText("By Name").should("exist");
|
||||
cy.findAllByText(/^By /).should("have.length", 4);
|
||||
|
||||
cy.findByText("Show more").scrollIntoView().click();
|
||||
|
||||
cy.findByText("By Source").should("exist");
|
||||
cy.findByText("By Title").should("exist");
|
||||
cy.findByText("By Vendor").should("exist");
|
||||
cy.findAllByText(/^By /).should("have.length", 7);
|
||||
});
|
||||
});
|
||||
|
||||
it("should edit, save, and cancel metric definition changes", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
cy.visit(`/metric/${metric.id}/query`);
|
||||
});
|
||||
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
|
||||
cy.log("cancel reverts changes");
|
||||
H.getNotebookStep("summarize").button("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total").click();
|
||||
});
|
||||
H.MetricPage.saveButton().should("be.visible");
|
||||
H.MetricPage.cancelButton().click();
|
||||
H.getNotebookStep("summarize").findByText("Count").should("be.visible");
|
||||
|
||||
cy.log("save persists changes");
|
||||
H.getNotebookStep("summarize").button("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total").click();
|
||||
});
|
||||
H.MetricPage.saveButton().click();
|
||||
cy.wait("@updateCard");
|
||||
H.getNotebookStep("summarize")
|
||||
.findByText("Sum of Total")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should add metric to dashboard and move to trash via more menu", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC, {
|
||||
wrapId: true,
|
||||
idAlias: "metricId",
|
||||
});
|
||||
|
||||
cy.get<number>("@metricId").then((metricId) => {
|
||||
H.visitMetric(metricId);
|
||||
});
|
||||
|
||||
cy.log("add to dashboard");
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Add to a dashboard").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByRole("heading", {
|
||||
name: "Add this metric to a dashboard",
|
||||
}).should("be.visible");
|
||||
cy.findByText("Orders in a dashboard").click();
|
||||
cy.button("Select").click();
|
||||
});
|
||||
cy.location("pathname").should(
|
||||
"eq",
|
||||
`/dashboard/${ORDERS_DASHBOARD_ID}-orders-in-a-dashboard`,
|
||||
);
|
||||
|
||||
cy.log("move to trash");
|
||||
cy.get<number>("@metricId").then((metricId) => {
|
||||
H.visitMetric(metricId);
|
||||
});
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Move to trash").click();
|
||||
H.modal().button("Move to trash").click();
|
||||
cy.wait("@updateCard");
|
||||
H.main().findByText("This metric is in the trash.");
|
||||
});
|
||||
|
||||
it("should restrict editing controls and definition tab for read-only users", () => {
|
||||
cy.signInAsAdmin();
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
cy.signIn("readonly");
|
||||
H.visitMetric(metric.id);
|
||||
|
||||
cy.log("about page hides editing controls");
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
cy.findByDisplayValue("Orders count").should("not.exist");
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Bookmark").should("be.visible");
|
||||
cy.findByText("Add to a dashboard").should("be.visible");
|
||||
cy.findByText("Move").should("not.exist");
|
||||
cy.findByText("Duplicate").should("not.exist");
|
||||
cy.findByText("Move to trash").should("not.exist");
|
||||
});
|
||||
|
||||
cy.log("overview and definition tabs are hidden for read-only users");
|
||||
cy.realPress("Escape");
|
||||
H.MetricPage.overviewTab().should("not.exist");
|
||||
H.MetricPage.definitionTab().should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ee features", () => {
|
||||
beforeEach(() => {
|
||||
H.activateToken("bleeding-edge");
|
||||
});
|
||||
|
||||
it("should show and hide 'Open in Data Studio' based on context", () => {
|
||||
createLibraryWithItems();
|
||||
|
||||
cy.request("GET", "/api/card").then(({ body: cards }) => {
|
||||
const metric = cards.find(
|
||||
(card: Card) =>
|
||||
card.type === "metric" && card.name === TRUSTED_ORDERS_METRIC.name,
|
||||
);
|
||||
|
||||
cy.log("metric page shows 'Open in Data Studio'");
|
||||
H.visitMetric(metric.id);
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Open in Data Studio").should("be.visible");
|
||||
cy.realPress("Escape");
|
||||
|
||||
cy.log("Data Studio route hides 'Open in Data Studio'");
|
||||
cy.visit(`/data-studio/library/metrics/${metric.id}`);
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Open in Data Studio").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to usage analytics dashboard from more menu", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
H.visitMetric(metric.id);
|
||||
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover()
|
||||
.findByText("Metric usage analytics")
|
||||
.closest("a")
|
||||
.should("have.attr", "href")
|
||||
.and("include", `question_id=${metric.id}`);
|
||||
|
||||
H.popover()
|
||||
.findByText("Metric usage analytics")
|
||||
.closest("a")
|
||||
.invoke("removeAttr", "target")
|
||||
.click();
|
||||
|
||||
cy.location("search").should("include", `question_id=${metric.id}`);
|
||||
H.main().findByText("Question overview").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the Dependencies tab with dependency graph in EE", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
H.visitMetric(metric.id);
|
||||
});
|
||||
|
||||
H.MetricPage.aboutPageDescriptionSidebar().within(() => {
|
||||
cy.findByText("Dependencies").should("be.visible");
|
||||
cy.findByText("Dependents").should("be.visible");
|
||||
});
|
||||
|
||||
H.MetricPage.dependenciesTab().click();
|
||||
H.DependencyGraph.graph().within(() => {
|
||||
cy.findByText("Table");
|
||||
cy.findByText("Orders").should("be.visible");
|
||||
cy.findByText("Orders count").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -142,14 +142,12 @@ describe("scenarios > metrics > dashboard", () => {
|
||||
it("should be possible to add metric to a dashboard via context menu (metabase#44220)", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(
|
||||
({ body: { id: metricId } }) => {
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
cy.visit(`/metric/${metricId}`);
|
||||
cy.wait("@dataset");
|
||||
cy.findByTestId("scalar-value").should("have.text", "18,760");
|
||||
H.visitMetric(metricId);
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
|
||||
cy.log("Add metric to a dashboard via context menu");
|
||||
H.openQuestionActions();
|
||||
H.popover().findByTextEnsureVisible("Add to dashboard").click();
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByTextEnsureVisible("Add to a dashboard").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByRole("heading", {
|
||||
name: "Add this metric to a dashboard",
|
||||
|
||||
@@ -66,451 +66,36 @@ const ORDERS_MULTI_STAGE_QUESTION = {
|
||||
display: "table",
|
||||
};
|
||||
|
||||
describe("scenarios > metrics > editing", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.resetSnowplow();
|
||||
cy.signInAsNormalUser();
|
||||
function startNewMetricWithTable(database, table) {
|
||||
H.startNewMetric();
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText(database).click();
|
||||
cy.findByText(table).click();
|
||||
});
|
||||
}
|
||||
|
||||
describe("organization", () => {
|
||||
it("should be able to rename a metric", () => {
|
||||
const newTitle = "New metric name";
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) => {
|
||||
H.visitMetric(card.id);
|
||||
renameMetric(newTitle);
|
||||
H.visitMetric(card.id);
|
||||
H.queryBuilderHeader()
|
||||
.findByDisplayValue(newTitle)
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to change the query definition of a metric", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) =>
|
||||
H.visitMetric(card.id),
|
||||
);
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metric definition").click();
|
||||
addBreakout({ tableName: "Product", columnName: "Created At" });
|
||||
updateMetric();
|
||||
verifyLineAreaBarChart({
|
||||
xAxis: "Product → Created At: Month",
|
||||
yAxis: "Count",
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to change the query definition of a metric based on a model", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_MODEL_METRIC).then(({ body: card }) =>
|
||||
H.visitMetric(card.id),
|
||||
);
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metric definition").click();
|
||||
addBreakout({ tableName: "Product", columnName: "Created At" });
|
||||
updateMetric();
|
||||
verifyLineAreaBarChart({
|
||||
xAxis: "Product → Created At: Month",
|
||||
yAxis: "Count",
|
||||
});
|
||||
});
|
||||
|
||||
it("should pin new metrics automatically", () => {
|
||||
cy.visit("/browse/metrics");
|
||||
cy.findByTestId("browse-metrics-header")
|
||||
.findByLabelText("Create a new metric")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("Verify metric_create_started event was tracked");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "metric_create_started",
|
||||
triggered_from: "browse_metrics",
|
||||
});
|
||||
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
saveMetric();
|
||||
|
||||
cy.log("Verify metric_created event was tracked");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "metric_created",
|
||||
triggered_from: "main_app",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
cy.log("Go to the collection this metric was saved in");
|
||||
cy.findByTestId("head-crumbs-container")
|
||||
.find('a[href*="collection"]')
|
||||
.click();
|
||||
|
||||
cy.findByTestId("pinned-items").within(() => {
|
||||
cy.findByRole("heading", { name: "Metrics" }).should("be.visible");
|
||||
verifyScalarValue("18,760");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not crash when cancelling creation of a new metric (metabase#48024)", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
cancelMetricEditing();
|
||||
});
|
||||
|
||||
it("should not crash when cancelling editing of an existing metric (metabase#48024)", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) =>
|
||||
H.visitMetric(card.id),
|
||||
);
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metric definition").click();
|
||||
addBreakout({ tableName: "Product", columnName: "Created At" });
|
||||
cancelMetricEditing();
|
||||
verifyScalarValue("18,760");
|
||||
});
|
||||
function startNewMetricWithSavedItem(collection, name) {
|
||||
H.startNewMetric();
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText(collection).click();
|
||||
cy.findByText(name).click();
|
||||
});
|
||||
}
|
||||
|
||||
describe("data source", () => {
|
||||
it("should create a metric based on a table", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
addStringCategoryFilter({
|
||||
tableName: "Product",
|
||||
columnName: "Category",
|
||||
values: ["Gadget"],
|
||||
});
|
||||
saveMetric();
|
||||
verifyScalarValue("4,939");
|
||||
});
|
||||
|
||||
it("should create a metric based on a saved question", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
addStringCategoryFilter({
|
||||
tableName: "Product",
|
||||
columnName: "Category",
|
||||
values: ["Gadget"],
|
||||
});
|
||||
saveMetric();
|
||||
verifyScalarValue("4,939");
|
||||
});
|
||||
|
||||
it("should create a metric based on a multi-stage saved question", () => {
|
||||
H.createQuestion(ORDERS_MULTI_STAGE_QUESTION);
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText(ORDERS_MULTI_STAGE_QUESTION.name).click();
|
||||
});
|
||||
addNumberBetweenFilter({
|
||||
columnName: "Count",
|
||||
minValue: 5,
|
||||
maxValue: 100,
|
||||
});
|
||||
saveMetric();
|
||||
verifyScalarValue("5");
|
||||
});
|
||||
|
||||
it("should create a metric based on a model", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText("Orders Model").click();
|
||||
});
|
||||
addStringCategoryFilter({
|
||||
tableName: "Product",
|
||||
columnName: "Category",
|
||||
values: ["Gadget"],
|
||||
});
|
||||
saveMetric();
|
||||
verifyScalarValue("4,939");
|
||||
});
|
||||
|
||||
it("should create a metric based on a multi-stage model", () => {
|
||||
H.createQuestion({ ...ORDERS_MULTI_STAGE_QUESTION, type: "model" });
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText(ORDERS_MULTI_STAGE_QUESTION.name).click();
|
||||
});
|
||||
addNumberBetweenFilter({
|
||||
columnName: "Count",
|
||||
minValue: 5,
|
||||
maxValue: 100,
|
||||
});
|
||||
saveMetric();
|
||||
verifyScalarValue("5");
|
||||
});
|
||||
|
||||
it("should not allow to create a multi-stage metric", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText("Orders Model").click();
|
||||
});
|
||||
getActionButton("Summarize").should("not.exist");
|
||||
});
|
||||
|
||||
it("should allow to run the query from the metric empty state", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
cy.findByTestId("metric-empty-state").button("Visualize").click();
|
||||
cy.wait("@dataset");
|
||||
verifyScalarValue("18,760");
|
||||
});
|
||||
function saveNewMetric({ name } = {}) {
|
||||
cy.intercept("POST", "/api/card").as("createCard");
|
||||
H.MetricPage.saveButton().click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Save your metric").should("be.visible");
|
||||
if (name) {
|
||||
cy.findByLabelText("Name").clear().type(name);
|
||||
}
|
||||
cy.button("Save").click();
|
||||
});
|
||||
|
||||
describe("joins", () => {
|
||||
it("should join a table", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Products").click();
|
||||
});
|
||||
startNewJoin();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewFilter();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("User").click();
|
||||
cy.findByText("State").click();
|
||||
cy.findByText("CA").click();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
saveMetric();
|
||||
verifyScalarValue("613");
|
||||
});
|
||||
|
||||
it("should not be possible to join a metric", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewJoin();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText("Orders").should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).should("not.exist");
|
||||
cy.findByText("Our analytics").click(); // go back
|
||||
});
|
||||
H.miniPickerBrowseAll().click();
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to join data on the first stage of a metric-based query", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).click();
|
||||
});
|
||||
H.getNotebookStep("data").within(() => {
|
||||
getActionButton("Custom column").should("be.visible");
|
||||
getActionButton("Join data").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom columns", () => {
|
||||
it("should be able to use custom columns in metric queries (metabase#42360)", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewCustomColumn();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "[Total] / 2",
|
||||
name: "Total2",
|
||||
});
|
||||
H.popover().button("Done").click();
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total2").click();
|
||||
});
|
||||
saveMetric();
|
||||
verifyScalarValue("755,310.84");
|
||||
});
|
||||
|
||||
it("should be able to use implicitly joinable columns in custom columns in metric queries (metabase#42360)", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewCustomColumn();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "[Product → Price] * 2",
|
||||
name: "Price2",
|
||||
});
|
||||
H.popover().button("Done").click();
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Average of ...").click();
|
||||
cy.findByText("Price2").click();
|
||||
});
|
||||
saveMetric();
|
||||
verifyScalarValue("111.38");
|
||||
});
|
||||
});
|
||||
|
||||
describe("breakouts", () => {
|
||||
it("should create a timeseries metric", () => {
|
||||
H.startNewMetric();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total").click();
|
||||
});
|
||||
addBreakout({ columnName: "Created At" });
|
||||
saveMetric();
|
||||
verifyLineAreaBarChart({
|
||||
xAxis: "Created At: Month",
|
||||
yAxis: "Sum of Total",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregations", () => {
|
||||
it("should create a metric with a custom aggregation expression based on 1 metric", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.startNewMetric();
|
||||
cy.intercept("POST", "/api/dataset/query_metadata").as("metadata");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).click();
|
||||
});
|
||||
cy.wait("@metadata");
|
||||
H.getNotebookStep("summarize")
|
||||
.findByText(ORDERS_SCALAR_METRIC.name)
|
||||
.click();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `[${ORDERS_SCALAR_METRIC.name}] / 2`,
|
||||
name: "",
|
||||
blur: true,
|
||||
});
|
||||
H.popover().button("Update").should("not.be.disabled").click();
|
||||
saveMetric();
|
||||
verifyScalarValue("9,380");
|
||||
});
|
||||
|
||||
it("should have metric-specific summarize step copy", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) =>
|
||||
H.visitMetric(card.id),
|
||||
);
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metric definition").click();
|
||||
|
||||
cy.log("regular screen");
|
||||
H.getNotebookStep("summarize").within(() => {
|
||||
cy.findByText("Formula").should("be.visible");
|
||||
cy.findAllByText("Default time dimension")
|
||||
.filter(":visible")
|
||||
.should("have.length", 1);
|
||||
});
|
||||
|
||||
cy.log("mobile screen");
|
||||
cy.viewport(800, 600);
|
||||
H.getNotebookStep("summarize").within(() => {
|
||||
cy.findByText("Formula").should("be.visible");
|
||||
cy.findAllByText("Default time dimension")
|
||||
.filter(":visible")
|
||||
.should("have.length", 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compatible metrics", () => {
|
||||
it("should allow adding an aggregation based on a compatible metric for the same table in questions (metabase#42470)", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.createQuestion(ORDERS_SCALAR_FILTER_METRIC);
|
||||
H.createQuestion(PRODUCTS_SCALAR_METRIC);
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewAggregation();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Metrics").click();
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_FILTER_METRIC.name).should("be.visible");
|
||||
cy.findByText(PRODUCTS_SCALAR_METRIC.name).should("not.exist");
|
||||
cy.findByText(ORDERS_SCALAR_MODEL_METRIC.name).should("not.exist");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).click();
|
||||
});
|
||||
H.visualize();
|
||||
verifyScalarValue("18,760");
|
||||
});
|
||||
|
||||
it("should for searching for metrics", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.createQuestion(ORDERS_SCALAR_FILTER_METRIC);
|
||||
H.createQuestion(PRODUCTS_SCALAR_METRIC);
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewAggregation();
|
||||
H.popover().within(() => {
|
||||
cy.findByPlaceholderText("Find...").type("with filter");
|
||||
cy.findByText("Metrics").should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).should("not.exist");
|
||||
cy.findByText(PRODUCTS_SCALAR_METRIC.name).should("not.exist");
|
||||
cy.findByText(ORDERS_SCALAR_MODEL_METRIC.name).should("not.exist");
|
||||
cy.findByText(ORDERS_SCALAR_FILTER_METRIC.name).should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the description for metrics", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_FILTER_METRIC);
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewAggregation();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Metrics").click();
|
||||
cy.findByText(ORDERS_SCALAR_FILTER_METRIC.name).should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_FILTER_METRIC.name).realHover();
|
||||
|
||||
cy.findByLabelText("More info").should("exist").realHover();
|
||||
});
|
||||
|
||||
H.hovercard().within(() => {
|
||||
cy.contains("This is a description").should("be.visible");
|
||||
cy.contains("with markdown").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
cy.wait("@createCard");
|
||||
}
|
||||
|
||||
function getActionButton(title) {
|
||||
return cy.findByTestId("action-buttons").button(title);
|
||||
@@ -582,39 +167,16 @@ function addBreakout({ tableName, columnName, bucketName, stageIndex }) {
|
||||
H.popover().findByText(tableName).click();
|
||||
}
|
||||
if (bucketName) {
|
||||
H.popover().findByLabelText(columnName).findByText("by month").click();
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
H.popover().last().findByText(bucketName).click();
|
||||
H.changeBinningForDimension({
|
||||
name: columnName,
|
||||
fromBinning: "by month",
|
||||
toBinning: bucketName,
|
||||
});
|
||||
} else {
|
||||
H.popover().findByText(columnName).click();
|
||||
}
|
||||
}
|
||||
|
||||
function saveMetric({ name } = {}) {
|
||||
cy.intercept("POST", "/api/card").as("createCard");
|
||||
cy.button("Save").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Save metric").should("be.visible");
|
||||
if (name) {
|
||||
cy.findByLabelText("Name").clear().type(name);
|
||||
}
|
||||
cy.button("Save").click();
|
||||
});
|
||||
cy.wait("@createCard");
|
||||
}
|
||||
|
||||
function updateMetric() {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
cy.button("Save changes").click();
|
||||
cy.wait("@updateCard");
|
||||
}
|
||||
|
||||
function renameMetric(newName) {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
cy.findByTestId("saved-question-header-title").clear().type(newName).blur();
|
||||
cy.wait("@updateCard");
|
||||
}
|
||||
|
||||
function verifyScalarValue(value) {
|
||||
cy.findByTestId("scalar-value").should("have.text", value).and("be.visible");
|
||||
}
|
||||
@@ -626,15 +188,402 @@ function verifyLineAreaBarChart({ xAxis, yAxis }) {
|
||||
});
|
||||
}
|
||||
|
||||
function cancelMetricEditing() {
|
||||
cy.log("click cancel but do not confirm");
|
||||
cy.button("Cancel").click();
|
||||
H.modal().button("Cancel").click();
|
||||
H.modal().should("not.exist");
|
||||
H.appBar().should("not.exist");
|
||||
describe("scenarios > metrics > editing", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.resetSnowplow();
|
||||
cy.signInAsNormalUser();
|
||||
});
|
||||
|
||||
cy.log("click cancel and confirm");
|
||||
cy.button("Cancel").click();
|
||||
H.modal().button("Discard changes").click();
|
||||
H.appBar().should("be.visible");
|
||||
}
|
||||
describe("organization", () => {
|
||||
it("should be able to rename a metric", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) => {
|
||||
H.visitMetric(card.id);
|
||||
});
|
||||
H.MetricPage.aboutPage()
|
||||
.findByDisplayValue(ORDERS_SCALAR_METRIC.name)
|
||||
.clear()
|
||||
.type("New metric name{enter}");
|
||||
cy.wait("@updateCard");
|
||||
H.MetricPage.aboutPage()
|
||||
.findByDisplayValue("New metric name")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should be able to change the query definition of a metric", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) =>
|
||||
cy.visit(`/metric/${card.id}/query`),
|
||||
);
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
addBreakout({ tableName: "Product", columnName: "Created At" });
|
||||
H.MetricPage.saveButton().click();
|
||||
H.MetricPage.saveButton().should("not.exist");
|
||||
H.MetricPage.aboutTab().click();
|
||||
verifyLineAreaBarChart({
|
||||
xAxis: "Product → Created At: Month",
|
||||
yAxis: "Count",
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to change the query definition of a metric based on a model", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
H.createQuestion(ORDERS_SCALAR_MODEL_METRIC).then(({ body: card }) =>
|
||||
cy.visit(`/metric/${card.id}/query`),
|
||||
);
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
addBreakout({ tableName: "Product", columnName: "Created At" });
|
||||
H.MetricPage.saveButton().click();
|
||||
cy.wait("@updateCard");
|
||||
H.MetricPage.aboutTab().click();
|
||||
verifyLineAreaBarChart({
|
||||
xAxis: "Product → Created At: Month",
|
||||
yAxis: "Count",
|
||||
});
|
||||
});
|
||||
|
||||
it("should pin new metrics automatically", () => {
|
||||
cy.visit("/browse/metrics");
|
||||
cy.findByTestId("browse-metrics-header")
|
||||
.findByLabelText("Create a new metric")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "metric_create_started",
|
||||
triggered_from: "browse_metrics",
|
||||
});
|
||||
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
saveNewMetric();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "metric_created",
|
||||
triggered_from: "main_app",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
H.MetricPage.header().findByText("Our analytics").click();
|
||||
|
||||
cy.findByTestId("pinned-items").within(() => {
|
||||
cy.findByRole("heading", { name: "Metrics" }).should("be.visible");
|
||||
verifyScalarValue("18,760");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not crash when cancelling creation or editing of a metric (metabase#48024)", () => {
|
||||
cy.log("cancel new metric creation");
|
||||
startNewMetricWithTable("Sample Database", "Orders");
|
||||
H.MetricPage.cancelButton().click();
|
||||
|
||||
cy.log("cancel editing an existing metric");
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) =>
|
||||
cy.visit(`/metric/${card.id}/query`),
|
||||
);
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
addBreakout({ tableName: "Product", columnName: "Created At" });
|
||||
H.MetricPage.cancelButton().click();
|
||||
H.getNotebookStep("summarize").findByText("Count").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("data source", () => {
|
||||
it("should create a metric based on a table", () => {
|
||||
startNewMetricWithTable("Sample Database", "Orders");
|
||||
addStringCategoryFilter({
|
||||
tableName: "Product",
|
||||
columnName: "Category",
|
||||
values: ["Gadget"],
|
||||
});
|
||||
saveNewMetric();
|
||||
verifyScalarValue("4,939");
|
||||
});
|
||||
|
||||
it("should create a metric based on a saved question", () => {
|
||||
startNewMetricWithSavedItem("Our analytics", "Orders");
|
||||
addStringCategoryFilter({
|
||||
tableName: "Product",
|
||||
columnName: "Category",
|
||||
values: ["Gadget"],
|
||||
});
|
||||
saveNewMetric();
|
||||
verifyScalarValue("4,939");
|
||||
});
|
||||
|
||||
it("should create a metric based on a multi-stage saved question", () => {
|
||||
H.createQuestion(ORDERS_MULTI_STAGE_QUESTION);
|
||||
startNewMetricWithSavedItem(
|
||||
"Our analytics",
|
||||
ORDERS_MULTI_STAGE_QUESTION.name,
|
||||
);
|
||||
addNumberBetweenFilter({
|
||||
columnName: "Count",
|
||||
minValue: 5,
|
||||
maxValue: 100,
|
||||
});
|
||||
saveNewMetric();
|
||||
verifyScalarValue("5");
|
||||
});
|
||||
|
||||
it("should create a metric based on a model", () => {
|
||||
startNewMetricWithSavedItem("Our analytics", "Orders Model");
|
||||
addStringCategoryFilter({
|
||||
tableName: "Product",
|
||||
columnName: "Category",
|
||||
values: ["Gadget"],
|
||||
});
|
||||
saveNewMetric();
|
||||
verifyScalarValue("4,939");
|
||||
});
|
||||
|
||||
it("should create a metric based on a multi-stage model", () => {
|
||||
H.createQuestion({ ...ORDERS_MULTI_STAGE_QUESTION, type: "model" });
|
||||
startNewMetricWithSavedItem(
|
||||
"Our analytics",
|
||||
ORDERS_MULTI_STAGE_QUESTION.name,
|
||||
);
|
||||
addNumberBetweenFilter({
|
||||
columnName: "Count",
|
||||
minValue: 5,
|
||||
maxValue: 100,
|
||||
});
|
||||
saveNewMetric();
|
||||
verifyScalarValue("5");
|
||||
});
|
||||
|
||||
it("should not allow to create a multi-stage metric", () => {
|
||||
startNewMetricWithSavedItem("Our analytics", "Orders Model");
|
||||
getActionButton("Summarize").should("not.exist");
|
||||
});
|
||||
|
||||
it("should allow to run the query from the metric empty state", () => {
|
||||
startNewMetricWithTable("Sample Database", "Orders");
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
H.runButtonInOverlay().click();
|
||||
cy.wait("@dataset");
|
||||
verifyScalarValue("18,760");
|
||||
});
|
||||
});
|
||||
|
||||
describe("joins", () => {
|
||||
it("should join a table", () => {
|
||||
startNewMetricWithTable("Sample Database", "Products");
|
||||
startNewJoin();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewFilter();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("User").click();
|
||||
cy.findByText("State").click();
|
||||
cy.findByText("CA").click();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
saveNewMetric();
|
||||
verifyScalarValue("613");
|
||||
});
|
||||
|
||||
it("should not be possible to join a metric", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
startNewMetricWithTable("Sample Database", "Orders");
|
||||
startNewJoin();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText("Orders").should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).should("not.exist");
|
||||
cy.findByText("Our analytics").click();
|
||||
});
|
||||
H.miniPickerBrowseAll().click();
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to join data on the first stage of a metric-based query", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).click();
|
||||
});
|
||||
H.getNotebookStep("data").within(() => {
|
||||
getActionButton("Custom column").should("be.visible");
|
||||
getActionButton("Join data").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom columns", () => {
|
||||
it("should be able to use custom columns and implicitly joinable columns in metric queries (metabase#42360)", () => {
|
||||
cy.log("custom column from same table");
|
||||
startNewMetricWithTable("Sample Database", "Orders");
|
||||
startNewCustomColumn();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "[Total] / 2",
|
||||
name: "Total2",
|
||||
});
|
||||
H.popover().button("Done").click();
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total2").click();
|
||||
});
|
||||
saveNewMetric();
|
||||
verifyScalarValue("755,310.84");
|
||||
|
||||
cy.log("custom column from implicitly joined table");
|
||||
startNewMetricWithTable("Sample Database", "Orders");
|
||||
startNewCustomColumn();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "[Product → Price] * 2",
|
||||
name: "Price2",
|
||||
});
|
||||
H.popover().button("Done").click();
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Average of ...").click();
|
||||
cy.findByText("Price2").click();
|
||||
});
|
||||
saveNewMetric();
|
||||
verifyScalarValue("111.38");
|
||||
});
|
||||
});
|
||||
|
||||
describe("breakouts", () => {
|
||||
it("should create a timeseries metric", () => {
|
||||
startNewMetricWithTable("Sample Database", "Orders");
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total").click();
|
||||
});
|
||||
addBreakout({ columnName: "Created At" });
|
||||
saveNewMetric();
|
||||
verifyLineAreaBarChart({
|
||||
xAxis: "Created At: Month",
|
||||
yAxis: "Sum of Total",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregations", () => {
|
||||
it("should create a metric with a custom aggregation expression based on 1 metric", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.startNewMetric();
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
cy.intercept("POST", "/api/dataset/query_metadata").as("metadata");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).click();
|
||||
});
|
||||
cy.wait("@metadata");
|
||||
H.getNotebookStep("summarize")
|
||||
.findByText(ORDERS_SCALAR_METRIC.name)
|
||||
.click();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `[${ORDERS_SCALAR_METRIC.name}] / 2`,
|
||||
name: "",
|
||||
blur: true,
|
||||
});
|
||||
H.popover().button("Update").should("not.be.disabled").click();
|
||||
saveNewMetric();
|
||||
verifyScalarValue("9,380");
|
||||
});
|
||||
|
||||
it("should have metric-specific summarize step copy", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) =>
|
||||
cy.visit(`/metric/${card.id}/query`),
|
||||
);
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
|
||||
H.getNotebookStep("summarize").within(() => {
|
||||
cy.findByText("Formula").should("be.visible");
|
||||
cy.findAllByText("Default time dimension")
|
||||
.filter(":visible")
|
||||
.should("have.length", 1);
|
||||
});
|
||||
|
||||
cy.viewport(800, 600);
|
||||
H.getNotebookStep("summarize").within(() => {
|
||||
cy.findByText("Formula").should("be.visible");
|
||||
cy.findAllByText("Default time dimension")
|
||||
.filter(":visible")
|
||||
.should("have.length", 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compatible metrics", () => {
|
||||
it("should allow adding an aggregation based on a compatible metric for the same table in questions (metabase#42470)", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.createQuestion(ORDERS_SCALAR_FILTER_METRIC);
|
||||
H.createQuestion(PRODUCTS_SCALAR_METRIC);
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewAggregation();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Metrics").click();
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_FILTER_METRIC.name).should("be.visible");
|
||||
cy.findByText(PRODUCTS_SCALAR_METRIC.name).should("not.exist");
|
||||
cy.findByText(ORDERS_SCALAR_MODEL_METRIC.name).should("not.exist");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).click();
|
||||
});
|
||||
H.visualize();
|
||||
verifyScalarValue("18,760");
|
||||
});
|
||||
|
||||
it("should for searching for metrics", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC);
|
||||
H.createQuestion(ORDERS_SCALAR_FILTER_METRIC);
|
||||
H.createQuestion(PRODUCTS_SCALAR_METRIC);
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewAggregation();
|
||||
H.popover().within(() => {
|
||||
cy.findByPlaceholderText("Find...").type("with filter");
|
||||
cy.findByText("Metrics").should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).should("not.exist");
|
||||
cy.findByText(PRODUCTS_SCALAR_METRIC.name).should("not.exist");
|
||||
cy.findByText(ORDERS_SCALAR_MODEL_METRIC.name).should("not.exist");
|
||||
cy.findByText(ORDERS_SCALAR_FILTER_METRIC.name).should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the description for metrics", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_FILTER_METRIC);
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
startNewAggregation();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Metrics").click();
|
||||
cy.findByText(ORDERS_SCALAR_FILTER_METRIC.name).should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_FILTER_METRIC.name).realHover();
|
||||
cy.findByLabelText("More info").should("exist").realHover();
|
||||
});
|
||||
|
||||
H.hovercard().within(() => {
|
||||
cy.contains("This is a description").should("be.visible");
|
||||
cy.contains("with markdown").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,29 @@ const ORDERS_TIMESERIES_METRIC = {
|
||||
display: "line",
|
||||
};
|
||||
|
||||
const MONTH_BREAKOUT = [
|
||||
"field",
|
||||
ORDERS.CREATED_AT,
|
||||
{ "base-type": "type/DateTime", "temporal-unit": "month" },
|
||||
];
|
||||
|
||||
function createQuestionWithMetric(
|
||||
metricId,
|
||||
{ display = "line", breakout } = {},
|
||||
) {
|
||||
const query = {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["metric", metricId]],
|
||||
};
|
||||
if (breakout) {
|
||||
query.breakout = [breakout];
|
||||
}
|
||||
return H.createQuestion(
|
||||
{ name: "Question with metric", type: "question", display, query },
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
}
|
||||
|
||||
describe("scenarios > metrics > question", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
@@ -54,7 +77,7 @@ describe("scenarios > metrics > question", () => {
|
||||
|
||||
it("should be able to move a metric to a different collection", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC, { visitQuestion: true });
|
||||
H.openQuestionActions();
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Move").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("First collection").click();
|
||||
@@ -64,30 +87,34 @@ describe("scenarios > metrics > question", () => {
|
||||
cy.findByText(/Metric moved to/).should("be.visible");
|
||||
cy.findByText("First collection").should("be.visible");
|
||||
});
|
||||
H.queryBuilderHeader().findByText("First collection").should("be.visible");
|
||||
H.MetricPage.header().findByText("First collection").should("be.visible");
|
||||
});
|
||||
|
||||
it("should be able to add a filter with an ad-hoc question", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC, { visitQuestion: true });
|
||||
H.filter();
|
||||
it("should be able to add a filter to a question that uses a metric", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
createQuestionWithMetric(metric.id, { display: "scalar" });
|
||||
});
|
||||
H.openNotebook();
|
||||
H.filter({ mode: "notebook" });
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Product").click();
|
||||
cy.findByText("Category").click();
|
||||
cy.findByText("Gadget").click();
|
||||
cy.button("Apply filter").click();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
H.visualize();
|
||||
cy.findByTestId("scalar-container")
|
||||
.findByText("4,939")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should be able to add a custom aggregation expression based on a metric", () => {
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC, { visitQuestion: true });
|
||||
cy.findByTestId("qb-header-action-panel")
|
||||
.button(/Summarize/)
|
||||
.click();
|
||||
cy.findByTestId("sidebar-content")
|
||||
.button(ORDERS_TIMESERIES_METRIC.name)
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC).then(({ body: metric }) => {
|
||||
createQuestionWithMetric(metric.id, { breakout: MONTH_BREAKOUT });
|
||||
});
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("summarize")
|
||||
.findByText(ORDERS_TIMESERIES_METRIC.name)
|
||||
.click();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `[${ORDERS_TIMESERIES_METRIC.name}] * 2`,
|
||||
@@ -95,39 +122,50 @@ describe("scenarios > metrics > question", () => {
|
||||
format: true,
|
||||
});
|
||||
H.popover().button("Update").should("not.be.disabled").click();
|
||||
H.visualize();
|
||||
H.echartsContainer().findByText("Expression").should("be.visible");
|
||||
});
|
||||
|
||||
it("should be able to add a breakout with an ad-hoc question", () => {
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC, { visitQuestion: true });
|
||||
cy.findByTestId("qb-header-action-panel")
|
||||
.button(/Summarize/)
|
||||
it("should be able to add a breakout to a question that uses a metric", () => {
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC).then(({ body: metric }) => {
|
||||
createQuestionWithMetric(metric.id, { breakout: MONTH_BREAKOUT });
|
||||
});
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("summarize")
|
||||
.findByTestId("breakout-step")
|
||||
.findByText("Created At: Month")
|
||||
.click();
|
||||
cy.findByTestId("sidebar-content").findByText("Category").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Product").click();
|
||||
cy.findByText("Category").click();
|
||||
});
|
||||
H.visualize();
|
||||
H.echartsContainer().findByText("Product → Category").should("be.visible");
|
||||
});
|
||||
|
||||
it("should be able to change the temporal unit when consuming a timeseries metric", () => {
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC, { visitQuestion: true });
|
||||
H.assertQueryBuilderRowCount(49);
|
||||
cy.findByTestId("qb-header-action-panel")
|
||||
.button(/Summarize/)
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC).then(({ body: metric }) => {
|
||||
createQuestionWithMetric(metric.id, { breakout: MONTH_BREAKOUT });
|
||||
});
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("summarize")
|
||||
.findByTestId("breakout-step")
|
||||
.findByText("Created At: Month")
|
||||
.click();
|
||||
cy.findByTestId("sidebar-content")
|
||||
.findByTestId("pinned-dimensions")
|
||||
.findByLabelText("Created At")
|
||||
.findByText("by month")
|
||||
.realHover()
|
||||
.click();
|
||||
H.popover().findByText("Year").click();
|
||||
H.changeBinningForDimension({
|
||||
name: "Created At",
|
||||
fromBinning: "by month",
|
||||
toBinning: "Year",
|
||||
});
|
||||
H.visualize();
|
||||
H.assertQueryBuilderRowCount(5);
|
||||
});
|
||||
|
||||
it("should be able to drill-thru with a metric", () => {
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC, { visitQuestion: true });
|
||||
H.cartesianChartCircle()
|
||||
.eq(23) // random dot
|
||||
.click({ force: true });
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC).then(({ body: metric }) => {
|
||||
createQuestionWithMetric(metric.id, { breakout: MONTH_BREAKOUT });
|
||||
});
|
||||
H.cartesianChartCircle().eq(23).click({ force: true });
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Break out by…").click();
|
||||
cy.findByText("Category").click();
|
||||
@@ -138,10 +176,10 @@ describe("scenarios > metrics > question", () => {
|
||||
});
|
||||
|
||||
it("should be able to drill-thru with a metric without the aggregation clause", () => {
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC, { visitQuestion: true });
|
||||
H.cartesianChartCircle()
|
||||
.eq(23) // random dot
|
||||
.click({ force: true });
|
||||
H.createQuestion(ORDERS_TIMESERIES_METRIC).then(({ body: metric }) => {
|
||||
createQuestionWithMetric(metric.id, { breakout: MONTH_BREAKOUT });
|
||||
});
|
||||
H.cartesianChartCircle().eq(23).click({ force: true });
|
||||
H.popover().findByText("See these Orders").click();
|
||||
cy.wait("@dataset");
|
||||
cy.findByTestId("qb-filters-panel")
|
||||
@@ -153,12 +191,12 @@ describe("scenarios > metrics > question", () => {
|
||||
it("should be able to view a table-based metric without data access", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) => {
|
||||
cy.signInAsSandboxedUser();
|
||||
H.visitMetric(card.id, { hasDataAccess: false });
|
||||
H.visitMetric(card.id);
|
||||
});
|
||||
cy.findByTestId("scalar-container")
|
||||
.findByText("18,760")
|
||||
.should("be.visible");
|
||||
cy.findByTestId("qb-header-action-panel").within(() => {
|
||||
H.MetricPage.aboutPage().within(() => {
|
||||
cy.button(/Filter/).should("not.exist");
|
||||
cy.button(/Summarize/).should("not.exist");
|
||||
});
|
||||
@@ -167,12 +205,12 @@ describe("scenarios > metrics > question", () => {
|
||||
it("should be able to view a model-based metric without data access", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_MODEL_METRIC).then(({ body: card }) => {
|
||||
cy.signInAsSandboxedUser();
|
||||
H.visitMetric(card.id, { hasDataAccess: false });
|
||||
H.visitMetric(card.id);
|
||||
});
|
||||
cy.findByTestId("scalar-container")
|
||||
.findByText("18,760")
|
||||
.should("be.visible");
|
||||
cy.findByTestId("qb-header-action-panel").within(() => {
|
||||
H.MetricPage.aboutPage().within(() => {
|
||||
cy.button(/Filter/).should("not.exist");
|
||||
cy.button(/Summarize/).should("not.exist");
|
||||
});
|
||||
@@ -191,27 +229,16 @@ describe("scenarios > metrics > question", () => {
|
||||
collection_id: FIRST_COLLECTION_ID,
|
||||
}).then(({ body: card }) => {
|
||||
cy.signIn("nocollection");
|
||||
H.visitMetric(card.id, { hasDataAccess: false });
|
||||
H.visitMetric(card.id);
|
||||
});
|
||||
cy.findByTestId("scalar-container")
|
||||
.findByText("18,760")
|
||||
.should("be.visible");
|
||||
cy.findByTestId("qb-header-action-panel").within(() => {
|
||||
H.MetricPage.aboutPage().within(() => {
|
||||
cy.button(/Filter/).should("not.exist");
|
||||
cy.button(/Summarize/).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show 'Replace existing question' option when saving an edited ad-hoc question from a metric (metabase#48555)", () => {
|
||||
cy.signInAsNormalUser();
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC, { visitQuestion: true });
|
||||
|
||||
H.summarize();
|
||||
cy.button("Done").click();
|
||||
|
||||
H.queryBuilderHeader().button("Save").click();
|
||||
H.modal().findByText("Replace or save as new?").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metrics", () => {
|
||||
@@ -233,12 +260,9 @@ describe("metrics", () => {
|
||||
{ ...ORDERS_SCALAR_METRIC, name: "Metric Baz" },
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
cy.findByTestId("qb-header").findByLabelText("Bookmark").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "bookmark_added",
|
||||
event_detail: "metric",
|
||||
triggered_from: "qb_action_panel",
|
||||
});
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByTextEnsureVisible("Bookmark").click();
|
||||
H.navigationSidebar().findByText("Metric Baz").should("be.visible");
|
||||
|
||||
H.navigationSidebar().findByText("Our analytics").click();
|
||||
cy.findAllByTestId("collection-entry")
|
||||
|
||||
@@ -29,10 +29,7 @@ describe("scenarios > metrics > search", () => {
|
||||
H.commandPalette()
|
||||
.findByRole("option", { name: ORDERS_SCALAR_METRIC.name })
|
||||
.click();
|
||||
cy.wait("@metricDataset");
|
||||
H.MetricsViewer.searchBarPills()
|
||||
.contains("Count of orders")
|
||||
.should("be.visible");
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
});
|
||||
|
||||
it("should be able to search for metrics on the search page", () => {
|
||||
@@ -53,25 +50,18 @@ describe("scenarios > metrics > search", () => {
|
||||
cy.findByText("1 result").should("be.visible");
|
||||
cy.findByText(ORDERS_SCALAR_METRIC.name).click();
|
||||
});
|
||||
cy.wait("@metricDataset");
|
||||
H.MetricsViewer.searchBarPills()
|
||||
.contains("Count of orders")
|
||||
.should("be.visible");
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
});
|
||||
|
||||
it("should see metrics in recent items in global search", () => {
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) => {
|
||||
H.visitMetric(card.id);
|
||||
cy.wait("@dataset");
|
||||
});
|
||||
H.navigationSidebar().findByText("Home").click();
|
||||
cy.visit("/");
|
||||
H.commandPaletteSearch(ORDERS_SCALAR_METRIC.name, false);
|
||||
H.commandPalette()
|
||||
.findByRole("option", { name: ORDERS_SCALAR_METRIC.name })
|
||||
.click();
|
||||
cy.wait("@metricDataset");
|
||||
H.MetricsViewer.searchBarPills()
|
||||
.contains("Count of orders")
|
||||
.should("be.visible");
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,9 +46,6 @@ describe("issue 47058", () => {
|
||||
cy.findByText("[Unknown Metric]").should("not.exist");
|
||||
|
||||
cy.wait("@metadata");
|
||||
cy.log(
|
||||
"Only renders the notebook editor (with the summarize button that has the metrics' name on it) after the metadata is loaded",
|
||||
);
|
||||
|
||||
cy.findByText("Loading...").should("not.exist");
|
||||
H.getNotebookStep("summarize").should("be.visible");
|
||||
@@ -98,7 +95,7 @@ describe("issue 44171", () => {
|
||||
cy.signInAsAdmin();
|
||||
|
||||
H.createQuestion(METRIC_A);
|
||||
H.createQuestion(METRIC_B, { visitQuestion: true });
|
||||
H.createQuestion(METRIC_B, { wrapId: true, idAlias: "metricBId" });
|
||||
H.createDashboard(
|
||||
{
|
||||
name: "Dashboard 44171",
|
||||
@@ -112,23 +109,23 @@ describe("issue 44171", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("saveCard");
|
||||
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
|
||||
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metric definition").click();
|
||||
H.getNotebookStep("summarize").button("Count").click();
|
||||
cy.get<number>("@metricBId").then((metricBId) => {
|
||||
cy.visit(`/metric/${metricBId}/query`);
|
||||
});
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total").click();
|
||||
});
|
||||
cy.button("Save changes").click();
|
||||
H.MetricPage.saveButton().click();
|
||||
cy.wait("@saveCard");
|
||||
|
||||
cy.get<number>("@dashboardId").then((id) => {
|
||||
H.visitDashboard(id);
|
||||
});
|
||||
|
||||
cy.get("@saveCard")
|
||||
.its("request.body")
|
||||
.its("visualization_settings")
|
||||
.should("not.exist");
|
||||
|
||||
H.editDashboard();
|
||||
cy.findByTestId("dashboard-header")
|
||||
.findByLabelText("Add questions")
|
||||
@@ -154,53 +151,52 @@ describe("issue 32037", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
H.createQuestion({
|
||||
name: "Metric 32037",
|
||||
type: "metric",
|
||||
display: "line",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
breakout: [
|
||||
[
|
||||
"field",
|
||||
ORDERS.CREATED_AT,
|
||||
{ "temporal-unit": "month", "base-type": "type/DateTime" },
|
||||
H.createQuestion(
|
||||
{
|
||||
name: "Metric 32037",
|
||||
type: "metric",
|
||||
display: "line",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
breakout: [
|
||||
[
|
||||
"field",
|
||||
ORDERS.CREATED_AT,
|
||||
{ "temporal-unit": "month", "base-type": "type/DateTime" },
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
{ wrapId: true, idAlias: "metricId" },
|
||||
);
|
||||
});
|
||||
|
||||
it("should show unsaved changes modal and allow to discard changes when editing a metric (metabase#32037)", () => {
|
||||
cy.visit("/browse/metrics");
|
||||
cy.findByLabelText("Metric 32037").click();
|
||||
|
||||
H.MetricsViewer.openMetricHomePage("Metric 32037");
|
||||
|
||||
H.cartesianChartCircle().should("be.visible");
|
||||
cy.location("pathname").as("metricPathname");
|
||||
H.openQuestionActions("Edit metric definition");
|
||||
cy.button("Save changes").should("be.disabled");
|
||||
H.filter({ mode: "notebook" });
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Total").click();
|
||||
cy.findByPlaceholderText("Min").type("0");
|
||||
cy.findByPlaceholderText("Max").type("100");
|
||||
cy.button("Add filter").click();
|
||||
cy.get<number>("@metricId").then((metricId) => {
|
||||
cy.visit(`/metric/${metricId}/query`);
|
||||
});
|
||||
cy.button("Save changes").should("be.enabled");
|
||||
cy.go("back");
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.MetricPage.saveButton().should("not.exist");
|
||||
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of ...").click();
|
||||
cy.findByText("Total").click();
|
||||
});
|
||||
|
||||
H.MetricPage.saveButton().should("be.visible");
|
||||
|
||||
H.MetricPage.aboutTab().click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Discard your changes?").should("be.visible");
|
||||
cy.findByText("Discard changes").click();
|
||||
});
|
||||
|
||||
H.appBar().should("be.visible");
|
||||
cy.button("Save changes").should("not.exist");
|
||||
cy.get("@metricPathname").then((metricPathname) => {
|
||||
cy.location("pathname").should("eq", metricPathname);
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
cy.get<number>("@metricId").then((metricId) => {
|
||||
cy.location("pathname").should("eq", `/metric/${metricId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -216,21 +212,22 @@ describe("issue 30574", () => {
|
||||
|
||||
cy.log("create the first metric");
|
||||
H.main().findByText("Create metric").click();
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
cy.findByTestId("edit-bar").button("Save").click();
|
||||
H.MetricPage.saveButton().click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").clear().type("M1");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
H.queryBuilderHeader().should("be.visible");
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
|
||||
cy.log("create the second metric");
|
||||
H.openNavigationSidebar();
|
||||
H.navigationSidebar().findByText("Metrics").click();
|
||||
H.main().findByLabelText("Create a new metric").click();
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
@@ -242,12 +239,11 @@ describe("issue 30574", () => {
|
||||
formula: "[M1]/[M1]",
|
||||
});
|
||||
H.popover().button("Update").click();
|
||||
cy.findByTestId("edit-bar").button("Save").click();
|
||||
H.MetricPage.saveButton().click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").clear().type("M2");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
H.queryBuilderHeader().should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,5 +47,8 @@ function openRevisionHistory() {
|
||||
|
||||
function revertTo(history) {
|
||||
const r = new RegExp(history);
|
||||
cy.findByText(r).closest("li").findByTestId("question-revert-button").click();
|
||||
cy.findByText(r)
|
||||
.closest('[data-testid="revision-history-event"]')
|
||||
.findByTestId("question-revert-button")
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -974,7 +974,7 @@ describe("issue 45926", () => {
|
||||
cy.findByText(
|
||||
"changed the visualization settings and edited the metadata.",
|
||||
)
|
||||
.closest("li")
|
||||
.closest('[data-testid="revision-history-event"]')
|
||||
.findByTestId("question-revert-button")
|
||||
.click();
|
||||
});
|
||||
|
||||
@@ -421,7 +421,7 @@ describe("command palette", () => {
|
||||
H.commandPaletteInput().should("exist").type("Me");
|
||||
cy.findByText("New metric").should("be.visible").click();
|
||||
|
||||
cy.location("pathname").should("eq", "/metric/query");
|
||||
cy.location("pathname").should("eq", "/metric/new");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -677,7 +677,7 @@ describe("issue 17910", () => {
|
||||
.blur();
|
||||
cy.findByRole("tab", { name: "History" }).click();
|
||||
cy.findByTestId("saved-question-history-list")
|
||||
.children()
|
||||
.findAllByTestId("revision-history-event")
|
||||
.should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1250,9 +1250,30 @@ describe("scenarios > question > offset", () => {
|
||||
display: "scalar",
|
||||
};
|
||||
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: card }) =>
|
||||
H.visitMetric(card.id),
|
||||
);
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
H.createQuestion(
|
||||
{
|
||||
name: "Question with metric",
|
||||
type: "question",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["metric", metric.id]],
|
||||
breakout: [
|
||||
[
|
||||
"field",
|
||||
ORDERS.CREATED_AT,
|
||||
{
|
||||
"base-type": "type/DateTime",
|
||||
"temporal-unit": "month",
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
display: "line",
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
});
|
||||
|
||||
H.openNotebook();
|
||||
|
||||
@@ -1263,7 +1284,9 @@ describe("scenarios > question > offset", () => {
|
||||
|
||||
H.visualize();
|
||||
|
||||
cy.findByTestId("chart-container").should("contain", "January 2024");
|
||||
H.echartsContainer().within(() => {
|
||||
cy.contains("January 2024").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,35 +8,26 @@
|
||||
[metabase.api.common :as api]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.mq.core :as mq]
|
||||
[metabase.query-processor.core :as qp]
|
||||
;; legacy usage -- don't do things like this going forward
|
||||
^{:clj-kondo/ignore [:deprecated-namespace :discouraged-namespace]} [metabase.query-processor.store :as qp.store]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.queue :as queue]
|
||||
[metabase.warehouse-schema.models.field-values :as field-values]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.util.concurrent ArrayBlockingQueue)))
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private ^ArrayBlockingQueue global-field-value-invalidate-queue
|
||||
"Queue used to recalculate the field values for updated columns in the background."
|
||||
(ArrayBlockingQueue. 1000))
|
||||
|
||||
(def ^:dynamic *field-value-invalidate-queue*
|
||||
"A layer of indirection on the actual [[field-value-invalidation-queue]], for testing."
|
||||
nil)
|
||||
|
||||
(defn- batch-invalidate-field-values!
|
||||
"Recalculate the field values for the given fields."
|
||||
[field-batches]
|
||||
(->> (t2/select :model/Field :id [:in (into #{} cat field-batches)])
|
||||
(run! field-values/create-or-update-full-field-values!)))
|
||||
|
||||
(defmethod queue/init-listener! ::FieldValueInvalidation [_]
|
||||
(queue/listen! "field-value-invalidate" global-field-value-invalidate-queue batch-invalidate-field-values!
|
||||
{:max-batch-messages 10, :max-next-ms 10}))
|
||||
(mq/def-listener! :queue/field-value-invalidation
|
||||
{:max-batch-messages 10}
|
||||
[messages]
|
||||
(batch-invalidate-field-values! (into [] cat messages)))
|
||||
|
||||
(defn select-table-pk-fields
|
||||
"Given a table-id, return the :model/Field instances corresponding to its PK columns. Do not assume any ordering."
|
||||
@@ -148,8 +139,8 @@
|
||||
(apply concat))]
|
||||
;; Note that for now we only rescan field values when values are *added* and not when they are *removed*.
|
||||
(when (seq stale-fields)
|
||||
(let [^ArrayBlockingQueue queue (or *field-value-invalidate-queue* global-field-value-invalidate-queue)]
|
||||
(.offer queue stale-fields)))))
|
||||
(mq/with-queue :queue/field-value-invalidation [q]
|
||||
(mq/put q [stale-fields])))))
|
||||
|
||||
;; TODO this is fairly dirty, would be cleaner to map from db values to de-coerced values via middleware
|
||||
;; invalidation could perhaps be done in response to effect, or in middleware (to dedupe for chained actions)
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
[metabase-enterprise.sso.settings :as ee-sso-settings]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.premium-features.core :as premium-features :refer [defenterprise]]
|
||||
[metabase.sso.settings :as sso-settings]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defenterprise ee-snowplow-features-data
|
||||
@@ -20,9 +19,6 @@
|
||||
{:name :sso-saml
|
||||
:available (premium-features/enable-sso-saml?)
|
||||
:enabled (ee-sso-settings/saml-enabled)}
|
||||
{:name :sso-slack
|
||||
:available (premium-features/enable-sso-slack?)
|
||||
:enabled (sso-settings/slack-connect-enabled)}
|
||||
{:name :scim
|
||||
:available (premium-features/enable-scim?)
|
||||
:enabled (boolean (scim/scim-enabled))}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
(tools.transforms/add-output (transforms-write-tools/get-transform-python-library-details {:path path})
|
||||
format-python-library-output))
|
||||
|
||||
(defenterprise-schema write-transform-python-tool
|
||||
(defenterprise-schema write-transform-python-tool :- :map
|
||||
"Write new Python code or edit existing code for transforms.
|
||||
|
||||
Supports two modes:
|
||||
@@ -45,21 +45,8 @@
|
||||
Keep `import common` at the top of the file even if it is currently unused."
|
||||
:feature :none
|
||||
[{:keys [transform_id edit_action thinking transform_name transform_description
|
||||
source_database source_tables]}
|
||||
:- [:map {:closed true}
|
||||
[:transform_id {:optional true} [:maybe :int]]
|
||||
[:edit_action [:map
|
||||
[:mode [:enum "edit" "replace"]]
|
||||
[:edits {:optional true} [:maybe [:sequential [:map
|
||||
[:old_string :string]
|
||||
[:new_string :string]
|
||||
[:replace_all {:optional true} [:maybe :boolean]]]]]]
|
||||
[:new_content {:optional true} [:maybe :string]]]]
|
||||
[:thinking {:optional true} [:maybe :string]]
|
||||
[:transform_name {:optional true} [:maybe :string]]
|
||||
[:transform_description {:optional true} [:maybe :string]]
|
||||
[:source_database {:optional true} [:maybe :int]]
|
||||
[:source_tables {:optional true} [:maybe :map]]]]
|
||||
database_id source_tables]}
|
||||
:- tools.transforms/write-transform-python-schema]
|
||||
(try
|
||||
(tools.transforms/add-output
|
||||
(transforms-write-tools/write-transform-python
|
||||
@@ -68,7 +55,7 @@
|
||||
:thinking thinking
|
||||
:transform_name transform_name
|
||||
:transform_description transform_description
|
||||
:source_database source_database
|
||||
:source_database database_id
|
||||
:source_tables source_tables
|
||||
:memory-atom shared/*memory-atom*
|
||||
:context (shared/current-context)})
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
{:agent-error? true
|
||||
:transform-id transform_id})))
|
||||
|
||||
current-python (get-in current-transform [:source :query] "")
|
||||
current-python (get-in current-transform [:source :body] "")
|
||||
|
||||
;; Apply edits based on mode
|
||||
new-python (case (:mode edit_action)
|
||||
@@ -70,12 +70,12 @@
|
||||
|
||||
;; Build suggested transform
|
||||
suggested-transform (cond-> current-transform
|
||||
true (assoc-in [:source :query] new-python)
|
||||
true (assoc-in [:source :type] "python")
|
||||
transform_name (assoc :name transform_name)
|
||||
transform_name (assoc :name transform_name)
|
||||
transform_description (assoc :description transform_description)
|
||||
source_database (assoc-in [:source :source-database] source_database)
|
||||
source_tables (assoc-in [:source :source-tables] source_tables))]
|
||||
;; source adjustments
|
||||
source_database (assoc-in [:source :source-database] source_database)
|
||||
source_tables (assoc-in [:source :source-tables] source_tables)
|
||||
true (assoc-in [:source :body] new-python))]
|
||||
|
||||
;; Store in memory if we have an ID
|
||||
(when (and transform_id memory-atom)
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
[snapshot {:keys [path-filters root-dependencies]}]
|
||||
(cond->> (ingestable/->IngestableSnapshot (cond-> snapshot
|
||||
(seq path-filters) (->WrappingSnapshot path-filters))
|
||||
(atom nil))
|
||||
(atom nil) (atom []))
|
||||
(seq root-dependencies) (ingestable/wrap-root-dep-ingestable root-dependencies)))
|
||||
|
||||
(defn- remote-sync-path
|
||||
|
||||
@@ -17,21 +17,30 @@
|
||||
(serialization/read-timestamps (yaml/parse-string file-content {:key-fn serialization/parse-key})))
|
||||
|
||||
(defn- ingest-all
|
||||
"Returns {:entities {stripped-hierarchy [hierarchy content]}, :errors [Exception...]}.
|
||||
Dotfiles are silently skipped (editor temp files, see #41567).
|
||||
Non-dotfile YAML parse/read failures are collected in :errors."
|
||||
[snapshot]
|
||||
(into {} (for [path (source.p/list-files snapshot)
|
||||
:when (and (not (str/starts-with? path "."))
|
||||
(str/ends-with? path ".yaml"))
|
||||
:let [content (try
|
||||
(source.p/read-file snapshot path)
|
||||
(catch Exception e
|
||||
(log/error e "Error reading file" path)))
|
||||
loaded (try
|
||||
(when content
|
||||
(serdes/path (ingest-content content)))
|
||||
(catch Exception e
|
||||
(log/error e "Error reading file" path)))]
|
||||
:when loaded]
|
||||
[(serialization/strip-labels loaded) [loaded content]])))
|
||||
(let [errors (atom [])]
|
||||
{:entities (into {} (for [path (source.p/list-files snapshot)
|
||||
:when (and (not (str/starts-with? path "."))
|
||||
(str/ends-with? path ".yaml"))
|
||||
:let [content (try
|
||||
(source.p/read-file snapshot path)
|
||||
(catch Exception e
|
||||
(log/warn (u/strip-error e "Error reading file during ingestion"))
|
||||
(swap! errors conj (ex-info (format "Failed to read file: %s" path) {:file path} e))
|
||||
nil))
|
||||
loaded (try
|
||||
(when content
|
||||
(serdes/path (ingest-content content)))
|
||||
(catch Exception e
|
||||
(log/warn (u/strip-error e "Error parsing file during ingestion"))
|
||||
(swap! errors conj (ex-info (format "Failed to parse file: %s" path) {:file path} e))
|
||||
nil))]
|
||||
:when loaded]
|
||||
[(serialization/strip-labels loaded) [loaded content]]))
|
||||
:errors @errors}))
|
||||
|
||||
;; Wraps another Ingestable calling a callback when a file is ingested
|
||||
(defrecord CallbackIngestable [ingestable callback]
|
||||
@@ -41,7 +50,10 @@
|
||||
|
||||
(ingest-one [_ serdes-path]
|
||||
(u/prog1 (serialization/ingest-one ingestable serdes-path)
|
||||
(callback <> serdes-path))))
|
||||
(callback <> serdes-path)))
|
||||
|
||||
(ingest-errors [_]
|
||||
(serialization/ingest-errors ingestable)))
|
||||
|
||||
(defn wrap-progress-ingestable
|
||||
"Wraps an Ingestable to track and update progress during ingestion.
|
||||
@@ -83,7 +95,10 @@
|
||||
root-dependencies))
|
||||
(serialization/ingest-list ingestable)))
|
||||
(ingest-one [_ serdes-path]
|
||||
(serialization/ingest-one ingestable serdes-path)))
|
||||
(serialization/ingest-one ingestable serdes-path))
|
||||
|
||||
(ingest-errors [_]
|
||||
(serialization/ingest-errors ingestable)))
|
||||
|
||||
(defn wrap-root-dep-ingestable
|
||||
"Wraps an Ingestable to filter items by root dependencies.
|
||||
@@ -96,17 +111,26 @@
|
||||
[root-dependencies ingestable]
|
||||
(->RootDependencyIngestable ingestable root-dependencies (atom {})))
|
||||
|
||||
(defn- populate-cache! [cache errors-atom ingest-fn]
|
||||
(when-not @cache
|
||||
(let [result (ingest-fn)]
|
||||
(reset! cache (:entities result))
|
||||
(reset! errors-atom (:errors result)))))
|
||||
|
||||
;; Wraps a snapshot object providing the ingestable interface for serdes
|
||||
(defrecord IngestableSnapshot [^SourceSnapshot snapshot cache]
|
||||
(defrecord IngestableSnapshot [^SourceSnapshot snapshot cache errors-atom]
|
||||
serialization/Ingestable
|
||||
(ingest-list [_]
|
||||
(keys (or @cache (reset! cache (ingest-all snapshot)))))
|
||||
(populate-cache! cache errors-atom #(ingest-all snapshot))
|
||||
(keys @cache))
|
||||
|
||||
(ingest-one [_ serdes-path]
|
||||
(when-not @cache
|
||||
(reset! cache (ingest-all snapshot)))
|
||||
(populate-cache! cache errors-atom #(ingest-all snapshot))
|
||||
(when-let [target (get @cache (serialization/strip-labels serdes-path))]
|
||||
(try
|
||||
(ingest-content (second target))
|
||||
(catch Exception e
|
||||
(throw (ex-info "Unable to ingest file" {:abs-path serdes-path} e)))))))
|
||||
(throw (ex-info "Unable to ingest file" {:abs-path serdes-path} e))))))
|
||||
|
||||
(ingest-errors [_]
|
||||
(or @errors-atom [])))
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
ingest-yaml
|
||||
ingest-list
|
||||
ingest-one
|
||||
ingest-errors
|
||||
read-timestamps
|
||||
parse-key]
|
||||
[metabase-enterprise.serialization.v2.load
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.date-2 :as u.date]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.yaml :as yaml]
|
||||
@@ -29,7 +30,12 @@
|
||||
(ingest-one
|
||||
[this path]
|
||||
"Given one of the `:serdes/meta` abstract paths returned by [[ingest-list]], read in and return the entire
|
||||
corresponding entity."))
|
||||
corresponding entity.")
|
||||
|
||||
(ingest-errors
|
||||
[this]
|
||||
"Return a vector of exceptions that occurred during ingestion (e.g. YAML parse failures).
|
||||
Returns [] if no errors occurred."))
|
||||
|
||||
(defn read-timestamps
|
||||
"Parses timestamp fields in an entity.
|
||||
@@ -76,34 +82,46 @@
|
||||
(def legal-top-level-paths "Known top-level paths for directory with serialization output"
|
||||
#{"actions" "collections" "databases" "glossary" "python-libraries" "snippets" "transforms"})
|
||||
|
||||
(defn- ingest-all [^File root-dir]
|
||||
;; This returns a map {unlabeled-hierarchy [original-hierarchy File]}.
|
||||
(into {} (for [^File file (file-seq root-dir)
|
||||
:when (and (.isFile file)
|
||||
(not (str/starts-with? (.getName file) "."))
|
||||
(str/ends-with? (.getName file) ".yaml")
|
||||
(let [rel (.relativize (.toPath root-dir) (.toPath file))]
|
||||
(-> rel (.subpath 0 1) (.toString) legal-top-level-paths)))
|
||||
;; TODO: only load YAML once.
|
||||
:let [hierarchy (try
|
||||
(serdes/path (ingest-file file))
|
||||
(catch Exception e
|
||||
(log/error e "Error reading file" (.getName file))))]
|
||||
:when hierarchy]
|
||||
[(strip-labels hierarchy) [hierarchy file]])))
|
||||
(defn- ingest-all
|
||||
"Returns {:entities {unlabeled-hierarchy [hierarchy File]}, :errors [Exception...]}.
|
||||
Dotfiles are silently skipped (editor temp files, see #41567).
|
||||
Non-dotfile YAML parse failures are collected in :errors."
|
||||
[^File root-dir]
|
||||
(let [errors (atom [])]
|
||||
{:entities (into {} (for [^File file (file-seq root-dir)
|
||||
:when (and (.isFile file)
|
||||
(not (str/starts-with? (.getName file) "."))
|
||||
(str/ends-with? (.getName file) ".yaml")
|
||||
(let [rel (.relativize (.toPath root-dir) (.toPath file))]
|
||||
(-> rel (.subpath 0 1) (.toString) legal-top-level-paths)))
|
||||
:let [file-name (.getName file)
|
||||
hierarchy (try
|
||||
(serdes/path (ingest-file file))
|
||||
(catch Exception e
|
||||
(log/warn (u/strip-error e "Error reading file during ingestion"))
|
||||
(swap! errors conj (ex-info (format "Failed to parse file: %s" file-name) {:file file-name} e))
|
||||
nil))]
|
||||
:when hierarchy]
|
||||
[(strip-labels hierarchy) [hierarchy file]]))
|
||||
:errors @errors}))
|
||||
|
||||
(deftype YamlIngestion [^File root-dir settings cache]
|
||||
(defn- populate-cache! [cache errors-atom ingest-fn]
|
||||
(when-not @cache
|
||||
(let [result (ingest-fn)]
|
||||
(reset! cache (:entities result))
|
||||
(reset! errors-atom (:errors result)))))
|
||||
|
||||
(deftype YamlIngestion [^File root-dir settings cache errors-atom]
|
||||
Ingestable
|
||||
(ingest-list [_]
|
||||
(-> (or @cache (reset! cache (ingest-all root-dir)))
|
||||
(populate-cache! cache errors-atom #(ingest-all root-dir))
|
||||
(-> @cache
|
||||
keys
|
||||
;; add settings ingestion paths
|
||||
(concat (for [k (keys settings)]
|
||||
[{:model "Setting" :id (name k)}]))))
|
||||
|
||||
(ingest-one [_ serdes-meta]
|
||||
(when-not @cache
|
||||
(reset! cache (ingest-all root-dir)))
|
||||
(populate-cache! cache errors-atom #(ingest-all root-dir))
|
||||
(let [{:keys [id]} (first serdes-meta)
|
||||
kw-id (keyword id)]
|
||||
(if (= ["Setting"] (mapv :model serdes-meta))
|
||||
@@ -114,10 +132,13 @@
|
||||
(ingest-file (second target))
|
||||
(catch Exception e
|
||||
(throw (ex-info "Unable to ingest file" {:file (.getName ^File (second target))
|
||||
:abs-path serdes-meta} e)))))))))
|
||||
:abs-path serdes-meta} e))))))))
|
||||
|
||||
(ingest-errors [_]
|
||||
(or @errors-atom [])))
|
||||
|
||||
(defn ingest-yaml
|
||||
"Creates a new Ingestable on a directory of YAML files, as created by
|
||||
[[metabase-enterprise.serialization.v2.storage.yaml]]."
|
||||
[root-dir]
|
||||
(->YamlIngestion (io/file root-dir) (yaml/from-file (io/file root-dir "settings.yaml")) (atom nil)))
|
||||
(->YamlIngestion (io/file root-dir) (yaml/from-file (io/file root-dir "settings.yaml")) (atom nil) (atom [])))
|
||||
|
||||
@@ -216,8 +216,18 @@
|
||||
;; guide the import, and make sure all containers are imported before contents, etc.
|
||||
(when backfill?
|
||||
(serdes.backfill/backfill-ids!))
|
||||
(let [contents (serdes.ingest/ingest-list ingestion)
|
||||
ctx (new-context ingestion)]
|
||||
(let [contents (serdes.ingest/ingest-list ingestion)
|
||||
ingest-errors (serdes.ingest/ingest-errors ingestion)
|
||||
ctx (cond-> (new-context ingestion)
|
||||
(seq ingest-errors) (update :errors into ingest-errors))]
|
||||
(when (and (seq ingest-errors) (not continue-on-error))
|
||||
(let [file-names (mapv #(or (:file (ex-data %)) (ex-message %)) ingest-errors)]
|
||||
(throw (ex-info (format "Failed to read %d file(s) during ingestion: %s"
|
||||
(count ingest-errors)
|
||||
(str/join ", " file-names))
|
||||
{:ingest-errors ingest-errors
|
||||
:files file-names}
|
||||
(first ingest-errors)))))
|
||||
(log/infof "Starting deserialization, total %s documents" (count contents))
|
||||
(reduce (fn [ctx item]
|
||||
(try
|
||||
@@ -235,4 +245,4 @@
|
||||
;; this means that the corresponding entries would have been missing or stale when we indexed them.
|
||||
;; ideally, we would delay the indexing somehow, or only reindex what we've loaded.
|
||||
;; while we're figuring that out, here's a crude stopgap.
|
||||
(search/reindex!)))))
|
||||
(search/async-reindex!)))))
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.session.core :as session]
|
||||
[metabase.sso.integrations.slack-connect :as slack-connect-integration]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
@@ -151,30 +150,6 @@
|
||||
(log/error e "Error handling SLO")
|
||||
(sso-error-page e :out))))
|
||||
|
||||
;; GET /auth/sso/slack-connect
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :get "/slack-connect"
|
||||
"Initiate Slack Connect SSO flow."
|
||||
[_route-params _query-params _body request]
|
||||
(try
|
||||
(slack-connect-integration/sso-initiate request)
|
||||
(catch Throwable e
|
||||
(log/error e "Error initiating Slack Connect SSO")
|
||||
(throw e))))
|
||||
|
||||
;; GET /auth/sso/slack-connect/callback
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :get "/slack-connect/callback"
|
||||
"Slack Connect OIDC callback."
|
||||
[_route-params _query-params _body request]
|
||||
(try
|
||||
(slack-connect-integration/sso-callback request)
|
||||
(catch Throwable e
|
||||
(log/error e "Error handling Slack Connect callback")
|
||||
(throw e))))
|
||||
|
||||
;; Key schema that excludes `/` so /:key does not greedily match /:key/callback
|
||||
(def ^:private ProviderKey
|
||||
[:string {:api/regex #"[a-z0-9][a-z0-9-]*"}])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user