Compare commits

...

188 Commits

Author SHA1 Message Date
Nathan Voxland
5ad741c0d2 Cleaning up code 2026-04-01 13:58:39 -05:00
Nathan Voxland
69570bc65f Cleaning up code 2026-03-31 20:15:55 -05:00
Nathan Voxland
b719615aa6 Cleaning up code 2026-03-31 17:09:51 -05:00
Nathan Voxland
afcc49f6f6 Refactor MQ test init: use :mq initialization step instead of is-test? checks
Replace scattered is-test? checks at var definition time with a proper
:mq initialization step in the test startup flow. This mirrors how
production initializes MQ via MqInit in startup/run-startup-logic!.

The :mq step:
- Loads driver.init (so def-listener! namespaces are loaded)
- Calls mq.init/init-for-tests! which sets sync backends, disables
  publish buffering, and registers all listeners

Also:
- Reverts var defaults to production values (appdb backends, 100ms buffer)
- Reverts def-listener! to lazy registration (no eager call)
- Restores with-sync-mq to use fresh *listeners* atom
- Removes all [TZ-DEBUG] diagnostic traces
- :db initialization depends on :mq

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:00:26 -05:00
Nathan Voxland
d12184f4a0 Fix eager listener registration: call multimethod, not method map lookup
The eager registration code used ((methods def-listener*) channel) which
returns the method fn from the methods map but doesn't invoke it. Changed
to (def-listener* channel) which dispatches through the multimethod and
actually calls the registration function.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:08:09 -05:00
Nathan Voxland
bde8a43aeb Add tracing to :db init to verify driver.init loading
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:27:56 -05:00
Nathan Voxland
76f8cb6ce3 Don't rebind *listeners* to empty atom in with-sync-mq
with-sync-mq was binding *listeners* to (atom {}), which created a
fresh empty atom that shadowed the root atom where listeners were
eagerly registered at def-listener! time. Since eftest propagates
bindings to worker threads, parallel tests running on threads spawned
within a with-sync-mq scope would see the empty atom and fail to find
any listeners.

Fix: stop rebinding *listeners*. Listeners are eagerly registered into
the root atom at namespace load time and should remain globally visible.
with-sync-mq still binds backends and buffer settings as needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:55:18 -05:00
Nathan Voxland
31840a1a31 Add atom identity tracing to diagnose listener registration
Trace which *listeners* atom instance is used during registration vs
lookup to detect if a dynamic rebinding is causing listeners to be
registered in one atom but looked up in another.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:35:43 -05:00
Nathan Voxland
a5968cdf24 Load driver/init.clj during test :db initialization
The MQ listeners defined in driver_notifications.clj (e.g.
connection-pool-invalidated) are only loaded via driver/init.clj, which
is loaded via core/init.clj in production. Tests don't load core/init.clj,
so these listener namespaces are never required and the eager registration
from def-listener! never happens.

Fix: require driver.init during test :db initialization so the listener
namespaces get loaded and listeners are registered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:37:56 -05:00
Nathan Voxland
5bcdea9b51 Eagerly register MQ listeners at def-listener! time
def-listener! now immediately registers the listener when the namespace
loads, instead of deferring to register-listeners! called from MqInit.
This ensures listeners are available in test mode where MqInit never runs
(tests use initialize-if-needed!, not startup/run-startup-logic!).

register-listener! is idempotent so the production path (MqInit calling
register-listeners!) still works fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:37:11 -05:00
Nathan Voxland
8eafca6c74 Add tracing to invoke-listener! to diagnose missing listener
The sync backend publish fires but listener output never appears.
Adding trace to see if listener is registered or not.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:57:50 -05:00
Nathan Voxland
6adf7f2ef8 Default MQ backends to sync in test mode
Same issue as *publish-buffer-ms*: MqInit never runs in tests, so the
alter-var-root that sets backends to sync has no effect. Set defaults
at var definition time based on config/is-test?.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:59:56 -05:00
Nathan Voxland
2ea7960d5c Default *publish-buffer-ms* to 0 in test mode
MqInit never runs in tests (tests use initialize-if-needed!, not
run-startup-logic!), so the alter-var-root in the previous fix had
no effect. Instead, set the default value at var definition time
based on config/is-test?.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:26:49 -05:00
Nathan Voxland
66af12b9cd Add more MQ tracing to diagnose CI failures
Traces at buffered-publish!, transport/publish!, sync-backend/publish!,
and MqInit to understand why *publish-buffer-ms* is still 100 in CI
(meaning messages are buffered instead of delivered immediately).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:02:26 -05:00
Nathan Voxland
8171f24196 Debugging CI failures 2026-03-31 00:27:49 -05:00
Nathan Voxland
a86dffa344 Fix MQ topic messages silently dropped in test mode
In test mode, MqInit sets sync backends but leaves *publish-buffer-ms*
at its default of 100ms. This causes buffered-publish! to buffer messages
instead of delivering them immediately. Since start-publish-buffer-flush!
is never called in test mode, no background thread drains the buffer,
so topic messages are silently dropped.

This broke report-timezone change notifications — connection pools were
never invalidated when timezone changed, causing stale timezone sessions
on database connections. This manifested as timezone-shifted query results
across 28+ driver tests in CI.

Fix: set *publish-buffer-ms* to 0 in test init path so messages publish
immediately without buffering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:56:59 -05:00
Nathan Voxland
db09e70823 Debugging CI failures 2026-03-30 20:27:04 -05:00
Nathan Voxland
407f3bc2df Merge branch 'master' into persistent-queue 2026-03-29 18:22:27 -05:00
Fraser
e9b56f49b5 Allow Enterprise module to import from feature modules (#71468) 2026-03-29 16:26:56 +13:00
Fraser
307b2906b7 Fix module stats upload script (#71385) 2026-03-29 16:26:43 +13:00
Nathan Voxland
5b1181eba5 Fixed module setup 2026-03-28 01:40:24 -05:00
Nathan Voxland
1574a28b4f Fixed tests 2026-03-28 01:26:44 -05:00
lbrdnk
b8d3ac0869 Adjust search schemas (#71572) 2026-03-27 16:13:42 -07:00
Alexander Polyankin
ad4dfb41b1 Fix typo in transform database validation messages (#71596) 2026-03-27 22:34:25 +00:00
Nathan Voxland
ace1e034a2 Fixed tests 2026-03-27 15:50:33 -05:00
Nathan Voxland
9142569d91 Fixed tests 2026-03-27 14:58:23 -05:00
github-automation-metabase
38e28e0ec3 Fix pagerDurty workflow edge case (#71574)
Fix PagerDuty alert broken by single quote in issue title

Use jq --arg to safely construct the JSON payload instead of
interpolating user-controlled data directly into a shell string.
This prevents shell syntax errors when issue titles contain single
quotes or other special characters.

Removes the now-unnecessary 'Setting title' step that only escaped
double-quotes but left single quotes unhandled.
2026-03-27 20:52:32 +01:00
Nathan Voxland
30c6275dc5 Fixed tests 2026-03-27 14:05:41 -05:00
Nathan Voxland
0f2e7ea906 Fixed tests 2026-03-27 13:51:36 -05:00
Nathan Voxland
cff57cbbb5 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	enterprise/backend/test/metabase_enterprise/agent_api/api_test.clj
2026-03-27 13:38:08 -05:00
Edward Paget
655a3f6ac7 Move agent API tests from enterprise to OSS (#71456) 2026-03-27 18:30:00 +00:00
Nathan Voxland
62c13bb028 Fixed formatting 2026-03-27 13:16:24 -05:00
Chris Truter
3c968befff Improve model encapsulation with serialization bypass (#71553) 2026-03-27 20:11:07 +02:00
Nathan Voxland
8f7064e87f Fixing tests 2026-03-27 12:56:26 -05:00
Sloan Sparger
334ed1fe42 [Metabot] Hide metabot feedback buttons on non-hosted instances (#71539)
hide metabot feedback buttons on non-hosted instances
2026-03-27 12:53:02 -05:00
Sloan Sparger
8e79105b9b [Metabot] Temporary removal of analyze chart button on dashboard page (#71540)
temp removal of analyze chart button on dashboard page
2026-03-27 12:52:53 -05:00
Ryan Laurie
92cd3f6cbf Converts some tests to ts (#71520)
converts tests to ts
2026-03-27 11:23:51 -06:00
Noah Moss
7089932b89 Move slack connect tests over to oss + fix API routes (#71525)
* move slack connect tests over to oss and remove some references to the token

* remove EE routes, always use OSS

* simplify

* undo some of that simplification

* fix tests / lint

* Move one test back to EE since it tests slack-connect's interaction with SAML
2026-03-27 10:50:48 -04:00
Nathan Voxland
27e5f298d8 Fixing tests 2026-03-27 09:27:36 -05:00
Nicolò Pretto
c4a27f18d3 embedding-hub: preserve "Select data" state + reset next steps after changing data segregation strategy (#71368)
* move up the state to persist it across steps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: reopen create tenants step when changing segregation strategy

When the user changes the data segregation strategy after already
creating tenants, the create tenants step now correctly reopens
instead of skipping to summary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* address feedback

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 15:24:32 +01:00
Phoomparin Mano
811e4466b2 Update in-app JWT onboarding to use snippets instead of iframes (#70475)
* share the same snippets for onboarding flow

* use sdk-docs-snippets namespace

* sync snippets

* fix rspack config

* rspack config: use path

* rspack: simplify resolve aliases

* remove node_modules from react refresh

* add copy button

* cleanup codemirror styling

* future proof: allow importing tsx files as well

* fix eslint pattern

* chore: move eslint ignore

* test to simplify aliases

* add context to ttag strings

---------

Co-authored-by: Nicolò Pretto <info@npretto.com>
2026-03-27 15:24:18 +01:00
Jeff Bruemmer
48c41ec6c8 Docs metabot slack (#71526) 2026-03-27 10:02:47 -04:00
Oleksandr Solovyov
b93319ab57 metabot llm calls: tools are optional/maybe (#71555) 2026-03-27 13:25:04 +00:00
lbrdnk
31905eaba4 Add 60 to version-helpers.ts (#71480)
Update versionRequirements
2026-03-27 11:02:22 +01:00
Nicolò Pretto
a0a14a42d3 scroll to top on page change in admin settings (#71369)
* scroll to top on page change in admin settings

* make tests green

* switch to useRouter instead of hoc
2026-03-27 09:55:04 +01:00
lbrdnk
839784b50c Fix transform writing tools (#71100)
* Add needed capabilities

* Partly fix transforms-write ns

* Partial fix to python transforms

* Add tool prompts

* Cleanup native transforms

* Add parameter schema descriptions

* Python schemas adjustments

* Adjust tool prompts

* Cleanup

* Tests

* Add capabilities test

* tests

* cleanup

* cleanup

* mbql5 and tests

* tests

* tests

* tests

* feature sentinel

---------

Co-authored-by: Tyler Plude <tsplude@gmail.com>
2026-03-27 08:44:57 +01:00
Nemanja Glumac
e9bc4ac447 Notify Slack on cross-version test failures (#71491) 2026-03-27 08:16:10 +01:00
Chris Truter
7180c6ae4b Optimize transform target table backfill migrations (#71487)
Better safe than sorry, given recent problems with expensive migrations.
2026-03-27 06:49:07 +02:00
Nick Fitzpatrick
a57a1c0ab8 preserve navbar status in the url update at the end of handleQBInit (#71505) 2026-03-27 09:59:12 +05:30
Nick Fitzpatrick
bee30e6423 Uxw 3355 page titles not in sync with data studio tabs (#71401)
* Fixing page titles in Data Studio

* shuffling Library files around. Adding library page title to all library subpages
2026-03-27 09:50:52 +05:30
Nick Fitzpatrick
281aa0dc12 decrease font size in click behavior sidebar (#71519) 2026-03-27 09:48:40 +05:30
Nick Fitzpatrick
14bd0853f6 set line height to 'normal' for textareas (#71498) 2026-03-27 09:48:20 +05:30
Romeo Van Snick
8be374db14 Convert query builder redux store to ts (#71063)
* Convert query_builder reducers to js

* Convert query_builder selectors to ts

* Fix types

* Fix types

* Fix types

* Fix isResultDirty selector

* Fix selector

* Move selector tests to TS

* Move reducers-typed into reducers

* Fix types

* Fix types

* Fix object id parsing

* Fix more types

* Fix PKRowIndexmap types

* Make name for EDIT_SUMMARY consistent

* Fix hanging test
2026-03-27 13:59:05 +11:00
Nathan Voxland
c6dff735a9 Fixing tests 2026-03-26 18:13:35 -05:00
Alexander Polyankin
b7e1d1eff8 Serdes for cards, segments, measures, transforms - allow deserializing only with minimal required properties without computed ones (#71464) 2026-03-26 18:51:58 -04:00
github-automation-metabase
3dd4c46871 Auto-update documentation for master (#71533)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-03-26 18:49:20 -04:00
Noah Moss
e7ae8072ca Reorganization + consolidation of metabot system prompts (#71506)
* Reorganization + consolidation of metabot system prompts

* More reorganization

* More shared prompt files

* revert search changes included accidentally

* more organization

* Fix dialect resource paths in module drivers

Update llm-sql-dialect-resource defmethods to use correct metabot/prompts/dialects/ path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* share personality prompt for nlq profile

* bump ci

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 17:27:26 -04:00
Riley Thompson
483f2086dd Bump databricks jdbc driver to 3.3.1 (#71513)
* bump databricks jdbc

* trigger ci
2026-03-26 21:17:38 +00:00
Edward Paget
cb9c042404 Fix MCP tool dispatch to pass query params correctly per Ring spec (#71502)
invoke-agent-api-with-params was encoding GET/DELETE query params into
the URI string, but defendpoint reads from :query-params (a parsed
map) on the Ring request. The synthetic request never passes through
wrap-params middleware, so query params in the URI were silently
ignored.

Fix by moving the method-based dispatch into deliver-agent-api-response:
POST params go to :body, GET/DELETE params go to :query-params.
Remove invoke-agent-api-with-params which is no longer needed.
2026-03-26 15:49:09 -05:00
Anderson
1b26236cf1 Do not show JWT shared secret as plaintext after it is saved (#71165)
* Handle JWT shared secret as write-only after initial generation (#UXW-3300)

- Thread generated token through wizard state so AddEndpointStep displays
  the plaintext key without re-reading it from session properties
- FormSecretKey now recognises the backend obfuscated format (**********XX)
  and renders an empty input with 'Already set' placeholder and a
  'Regenerate key' button, rather than showing the masked value

* Add unit tests for FormSecretKey obfuscated value handling (#UXW-3300)

- Tests covering no-value, plaintext, and obfuscated backend secret states
- Verifies 'Already set' placeholder and button labels are correct per state
- Verifies confirmation modal uses caller-supplied title/body
- Verifies token regeneration clears obfuscated value

* Fix prettier formatting in FormSecretKey.tsx

* Update the way we obfuscate the key and handle it on the frontend

* Add be test, improve alert

* Fix ts

* discard unnecessary changes

* Remove section that displayed the JWT secret in embedding set up

* Fix e2e test

* Show dedicated modal when generating new JWT shared key

* Fix unit test

* Fix e2e tests

* Fix latest UI changes

* Address review comments

* Add loading state to secret key modal and assert obfuscated key in E2E test

* Read jwt-shared-secret from /api/setting instead of session-properties / Revert unneeded obfuscated? implementation

* Fix tests

* Replace reads of session properties with api settings to simplify component logic
2026-03-26 16:34:54 -03:00
Chris Truter
14ff22021e Support non-datetime migration ids in tests (#71471)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:03:02 +02:00
Nick Fitzpatrick
55df67b632 Update Value normalization / collapsing in ChartSettingInputNumeric.tsx (#71440) 2026-03-27 00:14:41 +05:30
Edward Paget
53b678bcd6 Flatten root-level :or/:and/:multi into flat object schemas for MCP clients (#71494)
* Flatten root-level :or/:and/:multi into flat object schemas for MCP clients

Only the outermost composite schemas are flattened — nested anyOf/allOf
within properties are preserved since LLM clients handle them fine.

- :and → :merge then deref (flattens the [:map {:encode/...}] pattern)
- :or → :union with optional-keys, then deref (merges branches)
- :multi → same as :or, extracts schemas from dispatch triples

Also updates construct_query and query tool descriptions to explain
which fields are mutually exclusive (table_id vs metric_id vs
continuation_token).

* Refactor flatten-root-schema to worklist with cycle detection

Replace the recursive flatten-root-schema with a two-phase approach:
collect-root-maps walks the root composite tree using an iterative
worklist, then flatten-root-schema merges the collected leaves.

This eliminates stack recursion (:or/:multi no longer call
flatten-root-schema on children) and replaces the fuel counter with
a seen set that precisely detects deref cycles. On cycle detection,
emit an empty :map (-> {"type":"object"}) as a safe fallback.

The worklist also fixes :and wrapping :or — both layers now flatten
correctly since all composite nodes are expanded uniformly.

Adds tests for deeply nested composites, nested composites within
properties (preserved as anyOf/allOf), and :multi root flattening.
2026-03-26 17:48:52 +00:00
Nathan Voxland
daaca9ef62 CI fixes 2026-03-26 12:30:15 -05:00
Cam Saul
6a15fee1c5 Test to prove #32068 is fixed 🔧 (#71382)
* Test to prove #32068 is fixed 🔧

* Whoops revert deliberate test breakage

* Simplify test
2026-03-26 16:43:44 +00:00
Nicolò Pretto
92c07ddea1 hide sample db from embedding onboarding picker (#71304)
* hide sample db from embedding onboarding picker

* simplify e2e

* let's see if ci is green

* remove useless line, this is always pg
2026-03-26 17:23:19 +01:00
Sébastien
3dd99b6494 Fixed flaky custom elements api <metabase-question> test (#71500) 2026-03-26 17:20:53 +01:00
Riley Thompson
edb5eaad63 Handle non-dates in sqlite date columns (#71492)
* fix sqlite non date handling

* kondo
2026-03-26 12:14:00 -04:00
Nathan Voxland
ad9b980fef Merge remote-tracking branch 'origin/master' into persistent-queue 2026-03-26 11:12:10 -05:00
Nathan Voxland
96a308a64e CI fixes 2026-03-26 11:11:47 -05:00
Chris Truter
77afdaafc0 MCP further nits (#71478) 2026-03-26 16:08:18 +00:00
metamben
56f04b92ca Render |> as a single operator when formatting SQL (#71435)
Closes #70447
2026-03-26 16:06:37 +00:00
Braden Shepherdson
8020b65546 [MBQL lib] Add user docs for joins to lib.core (#71442)
Part of QUE2-366.
2026-03-26 16:06:19 +00:00
Sébastien
0cc35ac876 Sprinkled upsell gems in embed wizard (#71076) 2026-03-26 16:59:33 +01:00
Kevin Kelleher
617ca0f5f0 Fix boolean cartesian charts defaulting to timeseries axis (#71486) 2026-03-26 11:13:37 -04:00
John Swanson
607fb9ba58 Hide destination DBs from permissions (#71395)
* FE: do not show destination DBs in permissions editor

If you add a destination database, don't show it in the perms editor.

This was a temporary bug (disappears on refresh) BUT if you edit permissions
for the destination database then that is persisted, which is not great. I
will fix that in a separate PR.

* Don't add data permissions rows for destination DBs

- Exclude destination databases from the permissions graph

- Don't add permissions for destination DBs when creating a new group
2026-03-26 06:46:59 -08:00
Nathan Voxland
3c24edbf47 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	src/metabase/settings/models/setting.clj
2026-03-26 09:33:55 -05:00
Ngoc Khuat
74c8811981 Fix silent data loss when importing unparseable YAML files (#71421)
* Surface ingestion errors instead of silently skipping unparseable YAML files

The serdes ingestion layer was silently dropping YAML files that failed
to parse (introduced in #41887 as defensive coding for editor temp
files). This caused remote sync imports to silently lose entities when
YAML files were malformed or exceeded SnakeYAML's nesting depth limit,
with no error reported to the user.

Add `ingestion-errors` to the Ingestable protocol so the ingestion
layer collects parse failures and surfaces them through `load-metabase\!`.
When `continue-on-error` is false (default, used by remote sync), the
import fails immediately on ingestion errors. When true, errors are
collected and reported alongside load-phase errors.

The dotfile filter from #41567 is preserved — editor temp files are
still silently skipped before parsing is attempted.

Fixes #71257

* Rename ingestion-errors to ingest-errors, extract populate-cache\! helper, add e2e tests

- Rename protocol method to ingest-errors for consistency with ingest-list/ingest-one
- Extract populate-cache\! helper to DRY up duplicated cache-population logic
- Add e2e test for continue-on-error true/false with ingestion errors

* Add tests for ingest-errors on IngestableSnapshot and wrapper delegation

* Fix log/warnf misuse, tighten tests, remove stale comment

- log/warnf -> log/warn to prevent MissingFormatArgumentException
  when error messages contain % characters
- Add tests for ingest-errors ordering contract and multi-file corruption
- Tighten continue-on-error assertion to check exact error count
- Remove stale comment describing pre-fix behavior

* Include file names in ingestion error messages

Wrap parse/read exceptions with ex-info containing the file path,
so the error message shows which files failed, e.g.
"Failed to read 1 file(s) during ingestion: bad_card.yaml"
2026-03-26 23:29:58 +09:00
Oleksandr Solovyov
ce15b2d9f0 fix metabot sql tools incorrectly checked query type (#71428) 2026-03-26 14:06:13 +00:00
github-automation-metabase
e5db491df4 Auto-update documentation for master (#71454)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-03-26 09:54:44 -04:00
Chris Truter
fdffe7dc82 Add db fallback for renamed settings (#71470) 2026-03-26 15:42:32 +02:00
lbrdnk
3bd3bf5b6a Edit chart fix (#71424)
* Add edit_chart.md instructions

* Ensure ids of state elements are stringified

* Cleanup

* Test

* add test and do cleanup

* Review comments
2026-03-26 14:00:04 +01:00
Chris Truter
c940bb3935 Add FKs from transforms back to tables (#70617) 2026-03-26 13:40:22 +02:00
Timofey Kachalov
366c74c02e Support entityId for guest embedded questions (#71246) 2026-03-26 14:44:00 +04:00
Chris Truter
0303419907 Increase transform test timeout (#71453) 2026-03-26 12:26:05 +02:00
Sébastien
1511899a7e Implement proper column width measurement for custom font sizes (#71407) 2026-03-26 11:08:04 +01:00
lbrdnk
b1a2673e1a Inject context in user message (#71045)
* Add context injection to user message

* Remove context from internal template

* whitespace

* Adjust tests

* Apply suggestion from @appleby

Co-authored-by: appleby <86076+appleby@users.noreply.github.com>

* use lib

---------

Co-authored-by: appleby <86076+appleby@users.noreply.github.com>
2026-03-26 10:42:20 +01:00
Nicolò Pretto
7074e0236a fix frontmatter in add-tracing skill (#71438) 2026-03-26 09:38:16 +01:00
Nemanja Glumac
ddba65c368 Add E2E (questions) tests on top of the cross-version automation (#70190) 2026-03-26 09:13:57 +01:00
Nathan Voxland
93b375bbe2 Merged from master 2026-03-26 01:33:25 -05:00
Edward Paget
d8bb291939 Inline malli refs and flatten root anyOf in MCP tool schemas (#71457)
* Inline malli refs and flatten root anyOf in MCP tool schemas

MCP clients (Claude API, OpenAI, Gemini, AWS Bedrock) reject or
mishandle $ref and top-level anyOf in tool input schemas. Fix by:

- Walking malli schemas with ::walk-refs and ::walk-schema-refs to
  replace all registered-schema refs with their dereferenced content
  before JSON Schema generation, so mjs/transform never emits $ref
- Flattening root-level anyOf into a merged object schema for
  construct_query (table vs metric request union)
- Expanding construct_query tool description to explain table vs
  metric query differences since the schema no longer structurally
  separates them

Removes all $ref/$defs sanitization code and the defs accumulator
atom that are no longer needed.

* Replace :or/:and with :merge at malli level to eliminate anyOf/allOf

Instead of post-processing JSON Schema to flatten anyOf/allOf, handle
it at the malli schema level: in inline-malli-refs, replace :and with
:merge (eliminates allOf from the encode-map pattern) and :or with
:merge after making all entries optional via mut/optional-keys
(eliminates anyOf, all properties become optional in the merged
output).

This produces clean flat {type: "object", properties: {...}} schemas
that all MCP clients can handle. The construct_query tool now has all
table and metric properties in a single flat schema with none
required, and the description explains which to use.

Removes flatten-any-of and clojure.set dependency.
2026-03-26 06:21:33 +00:00
Nathan Voxland
ca3a2ea136 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	src/metabase/search/core.clj
#	src/metabase/search/ingestion.clj
#	src/metabase/search/task/search_index.clj
#	src/metabase/testing_api/api.clj
2026-03-25 23:49:31 -05:00
Aleksandr Lesnenko
1132bfd402 New Metric Home page (#71055) 2026-03-26 03:05:51 +00:00
Nathan Voxland
e9f6c93013 Improved performance benchmark and added back skip lock 2026-03-25 21:17:35 -05:00
Maksym Yakubych
37f3a01838 [Metabot] Bring your own keys (#70714)
* move ee/llm into oss

* lots of file movement

* move slack connect to opensource

* ability to have enterprise-only tools

* move tests

* Fix stale metabot-v3 aliases, dangling module refs, disabled linting, broken test paths, and typo

* start using deprecated-name, more ns path fixes

* move llm transforms code to oss

* fix namespaces

* whitespace fix

* checkpoint: switch tools to use mu/defn or defenterprise directly

* compilation fix

* lint fixes

* move around some code to break drivers dependency

* fix tests

* fix tool calls

* sort routes

* fix openapi

* fix module config

* lint

* fix clj-kondo

* fix test

* fix token check tests

* cljfmt

* fix slack test

* maybe fix dashboard tests?

* cljfmt

* is this a fix for dashboard tests

* move metabot models to oss

* untangle module config after rebase

* fix module order

* cleanup

* add back ai service-related settings that are still used

* move some more code to more appropriate places

* more code movement

* better schema names

* clean up mu/defn function name formatting

* update module config

* remove unneeded ai service-related test

* fix module config again

* fix auth in tests by removing more ai service stuff

* enable native agent by default

* test transforms over normal function calls

* requires

* pass through metabot-id to tool calls instead of hardcoding embedded-metabot-id, to fix answer sources test

* fix wrap-tools-with-state calls

* try to fix failing field/dashboard tests by matching the code on master (might have been from a bad rebase)

* Revert "try to fix failing field/dashboard tests by matching the code on master (might have been from a bad rebase)"

This reverts commit cd5ec8b487.

* hard revert parameters.dashboard-test to the version on master after the latest rebase

* mark snippet tests as sequential to try to fix perms test issues, remove a couple stray metabot feature checks

* move entity analysis + sql fixing code to oss

* wrap dashboard tests with with-full-data-perms-for-all-users to isolate the failure

* move ai-entity-analysis and ai-sql-fixer under the metabot module

* fix module test

* remove now-unneeded ai feature falgs

* disable perms cache for these failing dashboard tests

* feat: self-hosted metabot provider settings

* test: patch is-hosted temporarily

* fix: grouping

* move files to the frontend/ folder

* move MetabotProviderSection into MetabotSetup to accomidate design changes

* make anthropic the only allowed provider, updat sonnet 4.6 as default model, small layout change to api key input

* remove PLUGIN_METABOT usage, remove purchase pages, consolidate new connections settings ui

* remove oss impl of sql gen on the FE

* remove MetabotSettingUpModal, add alert for slackbot requiring metabot be setup, remove PLUGIN_METABOT, PLUGIN_METABOT.isCloudManaged checks now check llm-metabot-configured? which breaks things for enterprise for now

* add mock Metabase provider

* fix build

* impl copy feedback

* type-fix

* clean up

* fix unit tests

* fix metabot e2e tests

* type fix

* more test fixes

* fix semantic search tests

* more test fix

* kondo fix

* more test fixes

* copy change

* more kondo fixes

* app db test failure fixes

* fix failing BE tests

* fix e2e tests

* more e2e failure fixes

* fix typo

* remove mocked out endpoint definition

* fix last failing e2e test

---------

Co-authored-by: Oleksandr Solovyov <o@solovyov.net>
Co-authored-by: Noah Moss <noahbmoss@gmail.com>
Co-authored-by: Sloan Sparger <sloansparger@gmail.com>
2026-03-25 23:49:07 +00:00
Chris Truter
87dc660191 Minor improvements to MCP server (#71406)
* Fix token scope logic to match middleware

* Add :secure to CSRF and DRY up

* Add comment to magic number for clarity

* Clean up complex threading

* Don't clobber token-scopes

* Optimize and improve no user-id failure mode

* trim comment fluff

* Simplify and avoid double str call

* Use database time for revoke-token for robustness

* Use long for user-id actually

* Enable refresh token rotation

A stolen refresh token was reusable for its entire 30-day TTL with no
way to detect theft. The oidc-provider library supports rotation — on
each refresh grant the old token is revoked and a new one issued.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Sanitize brand-color on consent page to prevent CSS injection

The brand-color setting is interpolated via h/raw into a <style> block on
the consent page. A non-hex value could break out of the CSS rule.
Validate defensively at the rendering site rather than changing the setting
setter, to handle already-persisted invalid values gracefully.

* HMAC-bind consent params to CSRF token and sanitize error messages

Two security improvements to the OAuth server:

1. The authorization decision endpoint re-parsed OAuth parameters from
   the POST body without verifying they matched what the user saw on
   the consent page. A modified form could request broader scopes than
   shown. Now the authorize endpoint HMAC-signs the parsed params using
   the CSRF token as key, and the decision endpoint verifies the
   signature against the re-parsed params before proceeding.

2. Error catch blocks on /authorize, /authorize/decision, and /token
   returned raw exception messages that could leak internal details
   (client IDs, redirect URIs, expected vs actual values). Now returns
   generic RFC-compliant error descriptions and logs details server-side.

* Truncate long client name. Show client id

* Non-functional nits

* Clean up

* Align and extract helper

* DRY OCD

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:04:40 -04:00
Chris Truter
40691f70d8 Faster search index sync when loading snapshots (#71410) 2026-03-25 21:42:40 +00:00
Noah Moss
f4b0e5946b Consolidate metabot-enabled checks and add them to a number of endpoints (#71366)
* consolidate metabot-enabled checks and add them to a number of endpoints

* switch describe_tests to use mbql-query helper for cards

* Remove metabot.settings namespace import
2026-03-25 21:17:14 +00:00
Noah Moss
f3a2cfab7f Query pagination for MCP (#70971)
* Combined /v1/query endpoint with pagination and continuation tokens

- Add /v1/query that combines query construction and execution in one step
- Implement continuation_token-based pagination for large result sets
- Cap agent query rows at 200 per page; pagination stops at userland limits (2k/10k)
- Refactor construct-query into shared construct-query* helper
- Move agent-api dispatch behind defenterprise seam in EE MCP tools
- Add streaming JSON-RPC support for tool results in MCP layer
- Add json-string-escaping OutputStream for embedding streamed JSON in JSON-RPC
- Move /agent route out of naughty-routes into intentional top-level-routes
- Fix SSO test_setup to handle nil server port in CLI test runner

* Remove streaming JSON-RPC, buffer all tool responses in memory

Strip the streaming code path from the MCP layer so this branch
focuses purely on the query tool and pagination. Buffering ~120KB
for 200 rows is fine for the limits we enforce.

- Remove json-string-escaping OutputStream and streaming-tool-response
- Revert MCP api.clj to synchronous Ring form (no SSE streaming)
- Move StreamingResponse materialization into EE MCP tools layer
- Simplify invoke-agent-api to handle only plain map responses
- Remove streaming-specific tests (escaping, SSE streaming, error)

* a bunch of other changes to try get the rebased build working

* Roll back a bunch of fixes from the oss branch that don't need to be fixed here

* restore code accidentally lost via multiple rebases

* update modules + lint

* strip out dead ee code

* update tool  count test
2026-03-25 16:47:10 -04:00
Nathan Voxland
817eb1d787 Roll back transform perms (#71094) 2026-03-25 15:27:25 -05:00
Nathan Voxland
89786605c1 Fixing tests and created a mq-perf script 2026-03-25 15:19:29 -05:00
Egor Grushin
8b5224136b Cypress bail (#71420)
* add bail feature to the e2e stress test flake workflow

* add bail feature to the e2e stress test flake workflow

* add bail feature to the e2e stress test flake workflow

* Update e2e/support/cypress.js

Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>

* renamed BAIL to FAIL_FAST, replace Cypress.env with Cypress.expose

---------

Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>
2026-03-25 20:11:22 +00:00
Braden Shepherdson
051c1a6b25 [Params] Always return [] for param /values requests (#71302)
Fixes #71164.

On the BE, always return an empty list rather than nil. Old models with `:id` different from `:field_ref` in their metadata could result in `{:values nil ...}` being returned, which breaks the schema and FE. See #71164 for full details.
2026-03-25 20:11:07 +00:00
Jeff Bruemmer
c3c7feeddf docs - OIDC (#70892) 2026-03-25 13:54:47 -04:00
appleby
274b7de269 Remove dangling reference to use-native-agent setting in e2e tests (#71437)
This defsettings was removed in 311b735ae5
2026-03-25 16:48:29 +00:00
Stas Gavrylov
a0bea40dab GDGT-1755: Migrate common components from deprecated Popover to Mantine Popover (#70503)
* GDGT-1755: Migrate RightClickPopover to use Mantine

* GDGT-1755: Migrate DropdownSidebarFilter to Mantine Popover

* GDGT-1755: Migrate ParameterTargetWidget to Mantine Popover

* GDGT-1755: Make DropdownSidebarFilter close on Esc press

* GDGT-1755: Migrate GroupSelect to Mantine Popover

* GDGT-1755: Migrate deprecated Select to Mantine popover to remove deprecated Popover

* GDGT-1755: Cleanup. Delete unused deprecated components

* Fix prettier

* fix: fix test failures after Popover cleanup

- Remove jest.unmock for deleted Popover module in ChartSkeleton tests
- Add missing data-testid="popover" to DropdownSidebarFilter dropdown

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* GDGT-1755: Cleanup

* GDGT-1755: Address code review comments

* Clean up Popover.module.css: keep only tippy.js global styles

Remove unused module-level classes and obsolete tether-related classes.
Import the remaining global styles in TippyPopover.tsx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 18:33:12 +02:00
Nicolò Pretto
2576505465 fix flaky test in embedding hub (#71434) 2026-03-25 17:04:32 +01:00
Nick Fitzpatrick
9f5298fa9a Update TablePicker when users publish/unpublish tables, change name (#70954)
* update TablePicker when users publish/unpublish tables, change name

* refetch tables in useTableLoader

* update table owners
2026-03-25 21:27:23 +05:30
Kevin Kelleher
07f25c0cc7 Allow changing conditional formatting to color range when rule condition is set (#71079) 2026-03-25 10:59:01 -04:00
Nicolò Pretto
cfb3ab406c Implement SSO setup checklist locking for steps 2 and 3 until JWT is configured (#71345)
* Implement SSO setup checklist locking for steps 2 and 3 until JWT is configured

* remove e2e for something already covered by unit

* update checklist default values after merging master
2026-03-25 15:52:01 +01:00
Nathan Voxland
82f5dea54d Code cleanup 2026-03-25 02:28:42 -05:00
Nathan Voxland
ad62aed71a Cleaning up code 2026-03-24 02:15:25 -05:00
Nathan Voxland
3ab76adf8d Merged from master 2026-03-24 01:36:35 -05:00
Nathan Voxland
1f019c1e10 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	enterprise/backend/src/metabase_enterprise/action_v2/data_editing.clj
#	enterprise/backend/test/metabase_enterprise/action_v2/api_test.clj
#	enterprise/backend/test/metabase_enterprise/agent_api/api_test.clj
#	enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj
#	enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj
#	src/metabase/analytics/prometheus.clj
2026-03-24 01:28:52 -05:00
Nathan Voxland
3e4335a798 Merge remote-tracking branch 'origin/persistent-queue' into persistent-queue 2026-03-24 01:19:44 -05:00
Nathan Voxland
70967125aa Code cleanup 2026-03-24 01:16:36 -05:00
Nathan Voxland
5921542894 Code cleanup 2026-03-24 01:05:12 -05:00
Nathan Voxland
8c572f77f4 Fix race conditions in submit-delivery! and topic offset advancement
- submit-delivery!: atomic busy-check and registration via single swap!
  to prevent concurrent handlers for the same channel
- start-worker-pool!: use compare-and-set! to prevent pool leak on
  concurrent initialization
- Topic backends: only advance offset when submit-delivery! returns true,
  preventing silent message loss when channel is busy
2026-03-23 19:10:23 -05:00
Nathan Voxland
7e5bb21c3d Fix high-priority issues: retry flush failures, non-blocking batch processing, atomic after-commit drain
- Publish buffer: re-buffer messages on flush failure instead of dropping them, with Prometheus counter
- Queue/topic processing: centralized non-blocking dispatch via shared worker pool in mq.impl, with busy-channel tracking so poll threads never block on listener execution
- Queue heartbeats: poll thread updates status_heartbeat every 2min for in-flight batches to prevent stale recovery during long processing
- After-commit callbacks: atomically drain with reset-vals! loop to support callbacks that register new callbacks
- Document all-or-nothing ACK semantics on queue bundle retry in handle! docstring
2026-03-23 18:58:14 -05:00
Nathan Voxland
2c8c0e30aa Better consolidation of topic vs queue logic 2026-03-23 14:44:03 -05:00
Nathan Voxland
4ca03e17ef Took out now-unneeded search delay 2026-03-18 02:05:25 -05:00
Nathan Voxland
ec2b0f38ae Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	src/metabase/analytics/prometheus.clj
2026-03-18 01:46:46 -05:00
Nathan Voxland
2bad85564e Publish messages after an appdb transaction. Plus batch them still for 100ms to handle times we don't really have a transaction to avoid so many small batches 2026-03-18 01:45:26 -05:00
Nathan Voxland
d1252c7e8e Fixed merges from master 2026-03-13 11:59:15 -05:00
Nathan Voxland
4a7059cac7 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
#	src/metabase/search/appdb/core.clj
#	src/metabase/search/core.clj
#	src/metabase/search/impl.clj
#	src/metabase/search/ingestion.clj
#	src/metabase/search/task/search_index.clj
2026-03-13 11:09:28 -05:00
Nathan Voxland
9bd22bf4b1 Added dedup-fn feature 2026-03-10 02:03:18 -05:00
Nathan Voxland
0462cde56d Fixed kondo 2026-03-09 11:14:43 -05:00
Nathan Voxland
e99312fe64 Renamed def-listener to def-listener! 2026-03-09 10:24:36 -05:00
Nathan Voxland
d8a7cd3501 Fixing tests 2026-03-08 01:41:38 -06:00
Nathan Voxland
2d2c4b35a5 Fixing tests 2026-03-07 01:25:05 -06:00
Nathan Voxland
9e717a6056 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
2026-03-07 00:09:19 -06:00
Nathan Voxland
9ed603e6ce Better multi-backend handling 2026-03-06 17:30:39 -06:00
Nathan Voxland
8a5545ad32 Fixed whitespace and migrations 2026-03-06 16:01:30 -06:00
Nathan Voxland
82b89e31fb Fixing tests 2026-03-06 15:09:41 -06:00
Nathan Voxland
607985e054 Fixing tests 2026-03-05 22:15:42 -06:00
Nathan Voxland
a2e43e1996 Fixing tests 2026-03-05 21:23:23 -06:00
Nathan Voxland
3b96cd4619 Deps fix 2026-03-05 15:56:10 -06:00
Nathan Voxland
31790b6052 Added def-listener! and fixing tests 2026-03-05 15:42:23 -06:00
Nathan Voxland
846f38d443 Fixing tests 2026-03-05 02:00:51 -06:00
Nathan Voxland
e7a9e8c100 Removed other settings cache sync code 2026-03-05 01:20:07 -06:00
Nathan Voxland
e1b7b8e912 Use new queue better in search 2026-03-05 00:46:36 -06:00
Nathan Voxland
2480ce058b Support exclusive queue listeners 2026-03-04 11:27:19 -06:00
Nathan Voxland
cf04ba48c2 Immediately deliver topic posts locally without waiting for backend roundtrip 2026-03-04 09:51:00 -06:00
Nathan Voxland
8a853116bf Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	.clj-kondo/config.edn
2026-03-04 01:26:56 -06:00
Nathan Voxland
c54c9ded12 improving code 2026-03-04 01:20:12 -06:00
Nathan Voxland
0fc1a4b8c9 Code improvements and removed postgres topic listener 2026-03-04 00:18:19 -06:00
Nathan Voxland
6ca5625902 Code improvements 2026-02-27 12:47:50 -05:00
Nathan Voxland
a4fdcdef64 Code review fixes 2026-02-21 10:21:20 -06:00
Nathan Voxland
b1fe71ca2f Cleaning up force-sync usage 2026-02-21 02:29:02 -06:00
Nathan Voxland
4daf2cb8a2 Added batch-listen! 2026-02-21 01:52:57 -06:00
Nathan Voxland
4a3d739318 Started to migrate old util.queue code to new mq 2026-02-20 15:41:51 -06:00
Nathan Voxland
ba85c0513c Cleanup failed queue messages 2026-02-19 16:55:00 -06:00
Nathan Voxland
b8c26e3c0c Code review fixes 2026-02-19 16:48:09 -06:00
Nathan Voxland
21b5b3e93a Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
2026-02-19 16:20:28 -06:00
Nathan Voxland
0a1a13b48b Code cleanup 2026-02-19 16:09:11 -06:00
Nathan Voxland
a18f7a94e2 Code cleanup 2026-02-19 15:34:05 -06:00
Nathan Voxland
067626c71d Added metrics 2026-02-19 10:41:42 -06:00
Nathan Voxland
3bc10ee2e1 Cleanly shut down 2026-02-19 09:53:39 -06:00
Nathan Voxland
b9fba6ab7a Cleaning up topic code 2026-02-19 01:15:32 -06:00
Nathan Voxland
20e5611810 Cleaning up topic code 2026-02-18 16:26:15 -06:00
Nathan Voxland
37d68bca75 Made mq.topic code look like mq.queue code 2026-02-18 15:52:48 -06:00
Nathan Voxland
e58e0b7f80 Queue code improvements 2026-02-18 15:21:22 -06:00
Nathan Voxland
81aab7b8ca Added schemas 2026-02-18 14:15:45 -06:00
Nathan Voxland
bcf8a422f6 Queue code cleanup 2026-02-18 14:03:30 -06:00
Nathan Voxland
ac434a50aa Queue code cleanup 2026-02-18 13:02:49 -06:00
Nathan Voxland
4f050fba0a Use notify on postgres 2026-02-18 01:25:29 -06:00
Nathan Voxland
c36a72146e moved queue and pubsub to .mq 2026-02-18 00:27:53 -06:00
Nathan Voxland
d82ec362ba Added pub/sub messaging 2026-02-17 17:04:11 -06:00
Nathan Voxland
c2172311f5 Better queue testing with memory queue 2026-02-17 16:19:34 -06:00
Nathan Voxland
5a7bcfab93 Better naming for storing messages in the appdb queue 2026-02-17 15:37:43 -06:00
Nathan Voxland
decbc82b1a Code review fixes 2026-02-17 14:56:27 -06:00
Nathan Voxland
bd0f5b3b69 Merge remote-tracking branch 'origin/master' into persistent-queue 2026-02-17 14:33:54 -06:00
Nathan Voxland
7f98271a59 Don't have a queue_message model since it's an implementationd detail 2026-01-26 17:04:26 -06:00
Nathan Voxland
8cc219994d Merge remote-tracking branch 'origin/master' into persistent-queue 2026-01-26 16:44:09 -06:00
Nathan Voxland
84168dbf05 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
2026-01-26 15:27:21 -06:00
Nathan Voxland
d6da219a70 Fixes from code review and getting back to project 2026-01-16 16:28:37 -06:00
Nathan Voxland
65426fe653 Merge remote-tracking branch 'refs/remotes/origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
2026-01-16 16:11:19 -06:00
Nathan Voxland
6403d80fea Merge branch 'master' into persistent-queue 2026-01-14 11:11:53 -06:00
Nathan Voxland
bbcba1500c Added queue_message table back 2026-01-12 14:29:48 -06:00
Nathan Voxland
fcf4dbb6e8 Merge branch 'master' into persistent-queue
# Conflicts:
#	.clj-kondo/config/modules/config.edn
#	resources/migrations/056_update_migrations.yaml
2026-01-12 14:29:04 -06:00
Nathan Voxland
3d75754ad8 Added sucess and failure handling, general cleanup 2025-07-18 17:06:05 -05:00
Nathan Voxland
af72309d8e e2e test works again 2025-07-17 22:30:10 -05:00
Nathan Voxland
646b4c4548 Reverted metabase.queue changes, implemented metabase.queue.memory, code cleanup 2025-07-15 21:00:16 -05:00
Nathan Voxland
92ada9186c Code cleanup 2025-07-15 14:33:44 -05:00
Nathan Voxland
e67ce4582e Moved migrations 2025-07-14 23:07:20 -05:00
Nathan Voxland
25e3afcdbb Refactoring and trying to get e2e test to pass again 2025-07-14 23:07:20 -05:00
Nathan Voxland
b31a9a09c5 Configured queue listener to use persistent queue 2025-07-14 23:07:20 -05:00
Nathan Voxland
50614c15aa Initial implementation 2025-07-14 23:07:20 -05:00
728 changed files with 24672 additions and 20298 deletions

View File

@@ -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.
---

View File

@@ -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*

View File

@@ -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`
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

View 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}))

View File

@@ -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]]

View File

@@ -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: |

View File

@@ -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 }}' \

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1 @@
.xv-*

View File

@@ -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
View 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"

View File

@@ -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 ""

View File

@@ -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

View File

@@ -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)))

View File

@@ -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
View 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
View 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

View File

@@ -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

View File

@@ -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
View 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
![Metabot in Slack](./metabot-slack.png)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -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.
![Meet Metabot](./images/metabot.png)
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.

View File

@@ -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.

File diff suppressed because it is too large Load Diff

View File

@@ -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).
![Natural language questions in Slack](../ai/metabot-slack.png)
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)

View File

@@ -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.

View File

@@ -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.

View 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).

View 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.

View File

@@ -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

View File

@@ -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)

View 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");
});
});

View 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");
});
});

View 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");
});
});

View 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");
},
);
});

View 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");
});
});

View 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();
});
}

View 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}}`;

View 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;
},
},
});

View 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");
});
});

View 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");
});
});

View 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");
});
});

View 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");
},
);
});

View 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");
});
});

View 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
View 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[@]}"

View File

@@ -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,
},
});

View File

@@ -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";

View File

@@ -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");
}
/**

View File

@@ -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"),

View 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"),
};

View File

@@ -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}`);

View File

@@ -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}`);
}

View File

@@ -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");
}

View File

@@ -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";

View File

@@ -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");
});
});
});
});

View File

@@ -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 });

View File

@@ -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");

View File

@@ -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");

View File

@@ -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",
);
},
);
});
});

View File

@@ -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();

View File

@@ -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/);
});
});

View File

@@ -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");

View File

@@ -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");
});
});

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");

View File

@@ -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",

View File

@@ -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>

View File

@@ -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();

View File

@@ -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");
});
});

View File

@@ -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", () => {

View File

@@ -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");
});
});
});

View File

@@ -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");

View File

@@ -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();

View 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");
});
});
});
});

View File

@@ -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",

View File

@@ -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");
});
});
});
});

View File

@@ -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")

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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();
}

View File

@@ -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();
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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");
});
});
});

View File

@@ -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)

View File

@@ -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))}

View File

@@ -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)})

View File

@@ -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)

View File

@@ -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

View File

@@ -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 [])))

View File

@@ -26,6 +26,7 @@
ingest-yaml
ingest-list
ingest-one
ingest-errors
read-timestamps
parse-key]
[metabase-enterprise.serialization.v2.load

View File

@@ -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 [])))

View File

@@ -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!)))))

View File

@@ -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