Compare commits

...

73 Commits

Author SHA1 Message Date
Nicolò Pretto
20cf4af60f update file path to include tsx extension for component tests 2025-11-25 18:04:56 +01:00
Fraser
5aeffcf173 Skip BarChart Watermark loki (#66176) 2025-11-24 10:30:48 +00:00
Nemanja Glumac
721db4d40f Fetch uberjar using a proper action (#66165) 2025-11-21 23:58:57 +01:00
dpsutton
442fd726a3 Prevent driver tests (#66156)
Co-authored-by: Bryan Maass <bryan.maass@gmail.com>
2025-11-21 15:36:19 -07:00
Denis Berezin
9e65cc60bf Fix timeseries value detection (#66161) 2025-11-21 23:59:43 +03:00
Nemanja Glumac
44ab6decf0 Clean up backend-test matrix (#66158) 2025-11-21 20:40:40 +01:00
John Swanson
8b369333c3 Fix flaky Remote Sync test (#65862) 2025-11-21 11:29:00 -08:00
Nicolò Pretto
c9e602db2f chore(sdk): remove excessive logs in sdk fixup script (#66133) 2025-11-21 18:19:38 +01:00
Nemanja Glumac
b1fb103a3f Remove redundant static-viz files changed condition (#66138) 2025-11-21 17:41:36 +01:00
Jeff Bruemmer
b631ee88a4 docs - fix docstring (#66134)
* doc string

* edit
2025-11-21 11:39:49 -05:00
Riley Thompson
2df87d3a7f Add empty state to native query DB dropdown (#66090)
* add global search to db dropdown

* Add tests
2025-11-21 10:04:44 -05:00
Egor Grushin
f486c415f9 chore: Add egorgrushin as a team member (#66043)
* chore: Adds egorgrushin to ~/.github/team.json and ~/release/github-slack-map.json.

* chore: fixed the usernames order
2025-11-21 15:02:00 +00:00
Nemanja Glumac
ba5804041b Consolidate embedding CI workflows (#66075) 2025-11-21 11:40:00 +00:00
Nemanja Glumac
9d6564cdaf Fix backend cache paths (#66103) 2025-11-21 11:41:08 +01:00
Sébastien
591b453e4c Closes EMB-1037 preview_embed endpoint for filter search (#66077) 2025-11-21 09:11:52 +01:00
Sébastien
5d087d0d18 Closes EMB-1038 wrong endpoint for embed maps preview (#66063) 2025-11-21 09:11:43 +01:00
Riley Thompson
719ebd0a7b Fix caching of postgres domains in arrays (#66059)
* custom nippy handling for pgobject

* Add domain caching test

* simplify test comparison
2025-11-20 23:22:50 +00:00
Fraser
ff6ec328f3 Create use-page-title hook and remove SetTitle Hoc (#65782) 2025-11-21 10:59:40 +13:00
dpsutton
78978219a6 minimal tests to be run when the label is minimal-tests (#66024)
* minimal tests to be run when the label is `minimal-tests`

* include enterprise tests, fix syntax

* bump CI

* don't fail if label is absent
2025-11-20 15:31:11 -06:00
Jeff Bruemmer
7c8a8f3694 docs - update docs workflow (#66096)
* update docs workflow

* update perms

* more perms

* perms to delete pending reviews

* review deletion

* delete comments

* update skill
2025-11-20 16:23:51 -05:00
Jeff Bruemmer
45abe078f9 docs - supporting text (#66088)
* supporting text

* image

* edits
2025-11-20 15:06:42 -05:00
Jeff Bruemmer
82b95611cf docs - light weeding (#66023)
* spelling

* docstring
2025-11-20 14:21:28 -05:00
Noah Moss
f04fb1bff2 Fix template tag parsing for model references in transforms generated by metabot (#65630)
* Try to fix model references in transforms generated by metabot

* Add an e2e test

* move test later in file

* simplify implementation

* address bot comments

* add comment
2025-11-20 14:10:41 -05:00
Jeff Bruemmer
f200fbe20c docs - lychee action version (#66091)
* lychee action version

* bump versions
2025-11-20 14:09:01 -05:00
Jeff Bruemmer
3413313d3a docs - links workflow (#66087) 2025-11-20 13:46:09 -05:00
Nicolò Pretto
b5c0d93f8e chore(sdk): fix sdk storybook (#66078)
* chore(sdk): fix sdk storybook

* use rootRepoPath for appConfig
2025-11-20 18:24:13 +01:00
Edward Paget
71f7cbc41b fix: handle invalid email address in auth-identity (#66022)
...migrations.

Handles cases where emails were set to longer than the valid length of
254 bytes. Those emails would fail on this migration previously. We're
not currently using auth-identity to lookup email addresses so this
won't cause any incorrect behavior, but also if any emails like this
exist they weren't valid anyway.

Also sets the validCheckSum to ANY like some other SQL migrations to
handle cases where this migration has already been run successfully, as
on stats.
2025-11-20 10:59:43 -06:00
github-automation-metabase
c2619a5036 Auto-update documentation for master (#66056)
* Update generated docs (master)

* Update docs/configuring-metabase/environment-variables.md

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2025-11-20 14:24:59 +00:00
Nicolò Pretto
e4728d22b8 chore(sdk,ci): fix stress tests for sdk tests (#66065)
* chore(sdk,ci): fix stress tests for sdk tests

* fix baseUrl, cypress explodes if we pass it in ct test config
2025-11-20 11:37:10 +01:00
Timofey Kachalov
2d1b6c4a02 refactor(embed-js): Move EmbedJS + EmbedJS wizard files to OSS folder (#65973) 2025-11-20 14:32:30 +04:00
Ngoc Khuat
b8eb7d65f9 Add bottom padding for Attach results as files section (#62483) 2025-11-20 10:25:16 +01:00
Anderson
28b5fb79f1 Collect customer consent to grant instance access to Metabase success engineers (#65701)
* feat: add api for manage support access grants

* Setup frontend endpoint logic and start work on UI

* Implement create access grant; revoke; UI clean up

* Move section to tools > help / Adjust main UI

* feat: migration to new auth-identity module

* Fix schema migration tests

* Adjust backend fields structure on the backend / Update types and table in the UI

* Implement invitation mechanism and auth provider

* Create a route for the access grant modal

* Fix fe types / Fix clj spaces

* Add some test placeholders

* Remove stray #p

* Add missing index

* refix broken migration

* Change the way we temporarily make the new feature always enabled

* Add email sending on grant creation

* Add login and tests

* Make auths expirable

* Remove extraneous test

* Fix bad syntax in test

* Split two column migration into two migrations

* Fix test syntax issue

* Better expires at test

* Create e2e tests / Split troubleshooting.cy.spec and move to ts

* Add fe unit tests

* Add fe unit tests

* Fix kondo

* Fix config file and settings require

* Revert added disallowed exported namespaces

* Correctly test expiration

* Add tests for revoked at behavior

* Better better expires_at test

* Fix help.cy.spec.ts / Self review updates

* Fixup last issues with module config

* Make password reset is disabled for support users

Improve some email copy

* Real token check

* Move test relying on EE-code out of OSS code

* Send invitation notification after sso user creations

* Fix module config

* fixup! BE feedback

* fixup! altered not found msg

* Use user email as fallack when name is not set on support grants table

---------

Co-authored-by: Edward Paget <ed.paget@gmail.com>
Co-authored-by: Edward Paget <edpaget@users.noreply.github.com>
2025-11-19 23:59:02 -03:00
Sloan Sparger
53cf2ab305 [Metabot] Request cancellation (#65511)
* fix cut not removing text from input

* ensure we fetch all data for all possible models for smart links

* remove unused comment

* fixes smart links in metabot input sometimes degrading into their text representation and fixes some cases where wrong model name ("model" instead of "dataset") was used for metabot links

* fix unit tests

* ui for cancelling a request, handles state updates for canceled requests

* press esc to cancel metabot request, fix state being incorrect on requests after an aborted request

* add test coverage for ui, update test utils to enable aborting requests in unit tests, small bits of clean up

* add tests to processStream function

* update snapshot
2025-11-19 17:35:13 -06:00
Nemanja Glumac
135abb44a5 Consolidate caches (#66036) 2025-11-19 23:03:23 +01:00
Alex Yarosh
8699037548 Change images for development and production settings (#66039)
Updated images in the remote sync documentation to reflect new settings.
2025-11-19 20:50:12 +00:00
Alex Yarosh
03dad76c9f Update plans-blockquote inclusion for documents (#66038) 2025-11-19 15:49:12 -05:00
github-automation-metabase
dca22d0865 Bump org.postgresql/postgresql from 42.7.7 to 42.7.8 (#63804)
Inspect dependency changes here: https://github.com/pgjdbc/pgjdbc/blob/REL42.7.8/CHANGELOG.md

Co-authored-by: Cam Saul <1455846+camsaul@users.noreply.github.com>
2025-11-19 19:21:50 +00:00
github-automation-metabase
a6f98dbd68 Bump com.amazon.redshift/redshift-jdbc42 from 2.1.0.34 to 2.2.0 (#64830)
Inspect dependency changes here: https://github.com/aws/amazon-redshift-jdbc-driver/blob/v2.2.0/CHANGELOG.md

Co-authored-by: Cam Saul <1455846+camsaul@users.noreply.github.com>
2025-11-19 19:09:59 +00:00
github-automation-metabase
2fe9ebdec1 Bump org.apache.tika/tika-core from 3.2.2 to 3.2.3 (#63799)
Inspect dependency changes here:

Co-authored-by: Cam Saul <1455846+camsaul@users.noreply.github.com>
2025-11-19 19:03:52 +00:00
github-automation-metabase
c31762e244 Bump clj-kondo/clj-kondo from 2025.07.28 to 2025.10.23 (#65029)
* Bump clj-kondo/clj-kondo from 2025.07.28 to 2025.10.23

Inspect dependency changes here: https://github.com/clj-kondo/clj-kondo/blob/v2025.10.23/CHANGELOG.md

* Fix Kondo warning

---------

Co-authored-by: Cam Saul <github@camsaul.com>
2025-11-19 19:00:12 +00:00
Nicolò Pretto
5585815455 fix(docs): fix sdk docs not being generated correctly (#66030) 2025-11-19 11:58:18 -05:00
Jeff Bruemmer
2f8cc0f153 skills (#65676)
* skills

* docs review workflow

* update skills

* update bug fix workflow to use skills

* headings

* update code workflow to use skills

* updates

* add workflow dispatch to docs review

* edits
2025-11-19 16:54:05 +00:00
Jeff Bruemmer
d00a66f474 Fix YAML syntax for target_branch in workflow (#66018) 2025-11-19 09:50:38 -05:00
Braden Shepherdson
19ba6a165a [QP] Allow one-sided :number/between parameter values (#65987)
Fixes #65714.

These are sent as `:number/<=` or `:number/>=` and the validation was
too strict from 57 onward. Added a test for none, min, max, and both
ends of a Between filter.
2025-11-19 09:35:09 -05:00
Jeff Bruemmer
e0e3099787 Docs 57 latest (#65666)
* update release workflow to 57

* string
2025-11-19 09:27:52 -05:00
Jeff Bruemmer
6d62827416 docs - X version note and cleanup (#65756)
* X version note and cleanup

* note on hotfix x

* edit

* note on beta

* changelog link
2025-11-19 09:14:54 -05:00
Fraser
5a8ec6631a Cache yarn in CI (#65946) 2025-11-19 14:31:11 +01:00
Abed Habli
d5a7dc2e49 Docs: reusing snippet parameters (#65723)
* reusing snippet parameters

* Update docs/questions/native-editor/snippets.md

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

* updating the section

---------

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2025-11-19 13:25:17 +01:00
Thomas Schmidt
0a3a826537 Metabot tool endpoitns should support MBQL5 (#65844)
* Metabot tool endpoitns should support MBQL5

* Update filter tool to work with MBQL 5

* Fix find-outliers-wrong-query-test

Was attempting to assoc-in/dissoc-in MBQL4 query keys on an MBQL5 query.

---------

Co-authored-by: Mike Appleby <86076+appleby@users.noreply.github.com>
2025-11-19 10:32:39 +00:00
Fraser
04cbeab190 Remove ModalWithTrigger (#65994) 2025-11-19 10:22:51 +01:00
Nemanja Glumac
248a95e704 De-clutter the Cypress (nodejs) Module runner (#65881)
Running Cypress tests doesn't have to be hard.

This PR is moving us in a direction where (1) there's some logic that sets up the environment for e2e testing, and then (2) there's a simple command to invoke cypress (aka "decoupling" or "separation of concerns")

Cypress only needs to know which config to look at. It contains all the information it needs to run.

There are two ways to invoke Cypress:

1. Cypress cli
2. Cypress Module API (programmatic entry point)

They should be compatible and interchangeable. Anything you pass to the node module as options can be passed as individual flags to the cli.

```
npx cypress run --config-file path/to/some/config --spec path/to/spec.cy.spec.ts
```

is equivalent to

```
cypress.run({ configFile: "path/to/some/config", spec: "path/to/spec.cy.spec.ts" });
```

This PR removes all abstractions from the actual node module runner.
2025-11-19 10:20:14 +01:00
Kamil Mielnik
c7e2dcd254 GDGT-1235 Fix flaky dashboard parameters test (#65902)
* Fix flaky test

* Remove broken findDashCardAction helper
2025-11-19 15:17:19 +07:00
Fraser
fafd6bb3d8 Refactor ActionExecuteModal (#65573) 2025-11-19 14:38:44 +13:00
Fraser
4fe0ecbeb9 Refactor CustomURLPicker (#65574) 2025-11-19 14:23:46 +13:00
eliotmetabase
e1c31c0d8e Update label placeholder text to Optional for Gauge visualization (#65989)
* Update label placeholder text to Optional for Gauge visualization

* Update unit test
2025-11-18 17:04:14 -08:00
dpsutton
a8e592718f Turn off db routing for clickhouse and it's trick. (#65984)
* Turn off db routing for clickhouse and it's trick.

DB routing needs a "degree of freedom" to run the exact same query
against a different schema/db/instance whatever. Clickhouse only has
database.table. so to db route on the same clickhouse instance you have
to omit the database and let it resolve during query time.

`select * from db.table` can't work. Have to do `select * from table`
and let the connection dictate db1 or db2.

In 56, tables look like this:

```clojure
{:name "upload_records_10k_20251118164620", :schema "db_eafc4346850f435e"}
```

In 57, now they look like this for single db clickhouse things:

```clojure
{:name "upload_records_10k_20251118164620", :schema nil}
```

There are two critical problems here:
1. When uploading, it's not aware to omit the schema. So a table is
created with that schema. During the next sync that table is archived
and a new one with a nil'd schema is created. So the model created for
the uploaded CSV would break as it points to an inactive table. This has
a further compounding issue because now there "appear" to be two schema:
the faked nil one and also a new one like `db_eafc...`.
2. But _all_ tables will be archived in the same manner which were
initially found in 56 or below. These tables will be marked as inactive
and new ones created. Meaning all mbql queries based on clickhouse will
fail.

I wanted to revert this change but there's actually a bit more in the PR
and some conflicts. It also does more than just enabling db-routing so I
think we can perhaps undo the damage in this way more easily.

* remove outdated test
2025-11-18 18:45:46 -05:00
github-automation-metabase
0b5bbd260f Bump compojure/compojure from 1.7.1 to 1.7.2 (#63797)
Inspect dependency changes here: https://github.com/weavejester/compojure/blob/1.7.2/CHANGELOG.md

Co-authored-by: Cam Saul <1455846+camsaul@users.noreply.github.com>
2025-11-18 20:49:24 +00:00
github-automation-metabase
38c0aebd00 Bump io.github.eerohele/pp from 4998a1fdd9d3713d7034d04509e9212204773bf2 to ffc4edc482c057cd9412c62f018a41e9140c618b (#63473)
Inspect dependency changes here: 4998a1fdd9...ffc4edc482

Co-authored-by: Cam Saul <1455846+camsaul@users.noreply.github.com>
2025-11-18 20:40:55 +00:00
Riley Thompson
41b5904d52 Fix order by custom expression aggregations (#65937)
* fix bigquery order by aggregate custom expression

* add import

* make test work for all drivers
2025-11-18 13:58:53 -05:00
Alex Yarosh
a2975bdb44 Revise quickstart options for Embedded Analytics SDK (#65830)
Reorganized quickstart section for clarity based on user setup.
2025-11-18 12:41:33 -05:00
Edward Paget
e996561416 fix: allow analytics collection perms to be set (#65927)
* fix: allow analytics collection perms to be set

In #65098, we did not fully replicate the behavior of the old graph
endpoint where collection namespacing rules aren't applied to the
"analytics" namespace. Collections in this namespace can be modified as
though they are part of the root `nil` namespace. The bug was in
removing collections not part of the targetted namespace of the
`update-graph!` funciton from the graph to update.

This restores the old behavior by not excluding collection permissions
updates for collections in the "analytics" namespace in the
`remove-collections-from-other-namespaces` function.

it also updates our tests for the collection graph to make sure mock
analytics collections have the right namespace set rather than a `nil` namespace.

* fixup! reset revisions

* fixup! tests to EE module fix tests in EE module
2025-11-18 09:40:22 -06:00
Stas Gavrylov
10e76e3040 Update Table Editing e2e tests to use Postgres instead of SampleDB (#65955)
* Update Table Editing e2e tests to use Postgres instead of SampleDB

* Update table editing e2e tests to include different column types

* Address PR comments
2025-11-18 17:40:06 +02:00
Oleksandr Yakushev
25a97838db Bump Malli to 0.20.0 (#65909) 2025-11-18 17:15:12 +02:00
Brad Anderson
54c23276d7 Documents supporting text - Drag-and-drop (#65854)
Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-11-18 10:14:27 -05:00
Denis Berezin
58df0d448e Fix incorrect tooltip metric sum in row chart (#65938)
* Fix incorrect tooltip metric sum in row chart

* Review fixes
2025-11-18 18:08:51 +03:00
Nicola Mometto
35c649f88b Nicola/glossary creator api (#65961)
* feat: glossary, actually add creator_id on create endpoints

* feat: add creator_id tests on api
2025-11-18 15:08:26 +00:00
Nicolò Pretto
d981e64f0a move sdk bundle code to the oss folder (#65753)
* move the actual sdk folders

* update rspack config and eslint config

* fix relative import in CommonSdkStoryWrapper

* allow default exports in preview.tsx

* update eslint config to allow imports from frontend/src/embedding-sdk-shared

* remove legacy eslint disable directive

* temporarily 'disable' eslint-disable-next-line no-external-references-for-sdk-package-code that has not been ported yet

* update rspack config to use new paths

* update jest config to use new paths

* add `embedding-sdk-package` to restricted imports

* copy eslint files to apply to the new sdk folders

* add eslint-disable-next-line no-restricted-imports to some places where we need emotion

* fix jest setup

* add and use setupEmbeddingSdkEnterprisePlugins

* use `useSetting` instead of `getApplicationColors` to remove enterprise import

* move defineMetabaseAuthConfig to embedding-sdk-shared

* move MetabotProvider to embedding-sdk-bundle

* fix docs generation issue about types

* fix prettier ignore file

* move back tests to ee folder

* remove ../../../ from imports

* fix eslint issues

* fix, again, jest config :sad:
2025-11-18 15:56:43 +01:00
Sébastien
425856f92a Closes EMB-1021 flaky test e2e tests metabase browser read only mode (#65851)
* .

* .

* .
2025-11-18 15:33:42 +01:00
Anderson
5c87a6c12b Make sure 'Pull changes' button is not truncated on remote sync settings (#65932) 2025-11-18 10:41:45 -03:00
Sébastien
a033c12089 Closes EMB-982 embedding stuck on 404 when navigating with postMessage (#65662)
* Closes EMB-982 postmessage 404

* actual fix

* .

* .

* .
2025-11-18 12:51:32 +01:00
Nicola Mometto
39821ae14e Add creator to transform content view, and include glossary (#65957)
* feat: add creator_id to content view for transforms

* feat: add creator to glossary

* feat: glossary entries in content

* add creator test

* missing creator_id
2025-11-18 11:37:27 +00:00
Fraser
739b062e7c Refactor QuestionRowCount (#65837) 2025-11-18 23:10:34 +13:00
Phoomparin Mano
ec73bb3bae Allow content-disposition header in cross-origin requests (#65918) 2025-11-18 14:59:38 +07:00
693 changed files with 9891 additions and 4040 deletions

View File

@@ -0,0 +1,75 @@
## Linting and Formatting
- **Lint PR:** `./bin/mage kondo-updated master` (or whatever target branch)
- Call the command one time at the beginning, record the results, then work through the problems one at a time.
- If the solution is obvious, then please apply the fix. Otherwise skip it.
- If you fix all the issues (and verify by rerunning the kondo-updated command):
- commit the change with a succinct and descriptive commit message
- **Lint File:** `./bin/mage kondo <file or files>`
- Use the linter as a way to know that you are adhering to conventions in place in the codebase
- **Lint Changes:** `./bin/mage kondo-updated HEAD`
- **Format:** `./bin/mage cljfmt-files [path]`
## Testing
- **Run a test:** `./bin/mage run-tests namespace/test-name`
- **Run all tests in a namespace:** `./bin/mage run-tests namespace`
## Code Readability
- **Check Code Readability:** `./bin/mage -check-readable <file> [line-number]`
- Run after every change to Clojure code
- Check specific line first, then entire file if readable
## REPL Usage
- **Evaluating Clojure Code:** `./bin/mage -repl '<code>'`
- See "Sending Code to the REPL" section for more details
### Sending Code to the REPL
- Send code to the metabase process REPL using: `./bin/mage -repl '(+ 1 1)'` where `(+ 1 1)` is your Clojure code.
- See `./bin/mage -repl -h` for more details.
- If the Metabase backend is not running, you'll see an error message with instructions on how to start it.
#### Working with Files and Namespaces
1. **Load a file and call functions with fully qualified names**:
To call `your.namespace/your-function` on `arg1` and `arg2`:
```
./bin/mage -repl --namespace your.namespace '(your-function arg1 arg2)'
```
DO NOT use "require", "load-file" etc in the code string argument.
#### Understanding the Response
The `./bin/mage -repl` command returns three separate, independent outputs:
- `value`: The return value of the last expression (best for data structures)
- `stdout`: Any printed output from `println` etc. (best for messages)
- `stderr`: Any error messages (best for warnings and errors)
Example call:
```bash
./bin/mage -repl '(println "Hello, world!") '\''({0 1, 1 3, 2 0, 3 2} {0 2, 1 0, 2 3, 3 1})'
```
Example response:
```
ns: user
session: 32a35206-871c-4553-9bc9-f49491173d1c
value: ({0 1, 1 3, 2 0, 3 2} {0 2, 1 0, 2 3, 3 1})
stdout: Hello, world!
stderr:
```
For effective REPL usage:
- Return data structures as function return values
- Use `println` for human-readable messages
- Print errors to stderr

View File

@@ -0,0 +1,153 @@
# Metabase Clojure Style Guide
This guide covers Clojure and ClojureScript coding conventions for Metabase. See also: `CLOJURE_STYLE_GUIDE.adoc` for the Community Clojure Style Guide.
## Naming Conventions
**General Naming:**
- Acceptable abbreviations: `acc`, `i`, `pred`, `coll`, `n`, `s`, `k`, `f`
- Use `kebab-case` for all variables, functions, and constants
**Function Naming:**
- Pure functions should be nouns describing the value they return (e.g., `age` not `calculate-age` or `get-age`)
- Functions with side effects must end with `!`
- Don't repeat namespace alias in function names
**Destructuring:**
- Map destructuring should use kebab-case local bindings even if the map uses `snake_case` keys
## Documentation Standards
**Docstrings:**
- Every public var in `src` or `enterprise/backend/src` must have docstring
- Format using Markdown conventions
- Reference other vars with `[[other-var]]` not backticks
**Comments:**
- `TODO` format: `;; TODO (Name M/D/YY) -- description`
## Code Organization
**Visibility:**
- Make everything `^:private` unless it is used elsewhere
- Try to organize namespaces to avoid `declare` (put public functions near the end)
**Size and Structure:**
- Break up functions > 20 lines
- Lines ≤ 120 characters
- No blank lines within definition forms (except pairwise `let`/`cond`)
## Style Conventions
**Keywords and Metadata:**
- Prefer namespaced keywords for internal use: `:query-type/normal` not `:normal`
- Tag variables with `:arglists` metadata if they're functions but wouldn't otherwise have it
## Tests
**Organization:**
- Break large tests into separate `deftest` forms for logically separate test cases
- Test names should end in `-test` or `-test-<number>`
**Performance:**
- Mark pure function tests `^:parallel`
## Modules
**OSS Modules:**
- Follow `metabase.<module>.*` pattern
- Source in `src/metabase/<module>/`
**Enterprise Modules:**
- Follow `metabase-enterprise.<module>.*` pattern
- Source in `enterprise/backend/src/metabase_enterprise/<module>/`
**Module Structure:**
- REST API endpoints go in `<module>.api` or `<module>.api.*` namespaces
- Put module public API in `<module>.core` using Potemkin imports
- Put Toucan models in `<module>.models.*`
- Put settings in `<module>.settings`
- Put schemas in `<module>.schema`
**Module Linters:**
- Do not cheat module linters with `:clj-kondo/ignore [:metabase/modules]`
## REST API Endpoints
**Required Elements:**
- All new endpoints must have response schemas (`:- <schema>` after route string)
- All endpoints need Malli schemas for parameters (detailed and complete)
- All new REST API endpoints MUST HAVE TESTS
**Naming Conventions:**
- Query parameters use kebab-case
- Request bodies use `snake_case`
- Routes use singular nouns (e.g., `/api/dashboard/:id`)
**Behavior:**
- `GET` endpoints should not have side effects (except analytics)
- `defendpoint` forms should be small wrappers around Toucan model code
## MBQL (Metabase Query Language)
**Restrictions:**
- No raw MBQL introspection outside of `lib`, `lib-be`, or `query-processor` modules
- Use Lib and MBQL 5 in new source code; avoid legacy MBQL
## Database and Models
**Naming:**
- Model names and table names should be singular nouns
- Application database uses `snake_case` identifiers
**Best Practices:**
- Use `t2/select-one-fn` instead of fetching entire rows for one column
- Put correct behavior in Toucan methods, not separate helper functions
## Drivers
**Documentation:**
- New driver multimethods must be mentioned in `docs/developers-guide/driver-changelog.md`
**Implementation:**
- Driver implementations should pass `driver` argument to other driver multimethods
- Don't hardcode driver names in implementations
- Minimize logic inside `read-column-thunk` in JDBC-based drivers
## Miscellaneous
**Examples:**
- Example data should be bird-themed if possible
**Linter Suppressions:**
- Use proper format for kondo suppressions
- No `#_:clj-kondo/ignore` (keyword form)
**Configurable Options:**
- Don't define configurable options that can only be set with environment variables
- Use `:internal` `defsetting` instead

View File

@@ -0,0 +1,8 @@
# Autonomous Development Workflow
- Do not attempt to read or edit files outside the project folder
- Add failing tests first, then fix them
- Work autonomously in small, testable increments
- Run targeted tests, and lint continuously during development
- Prioritize understanding existing patterns before implementing
- Don't commit changes, leave it for the user to review and make commits

View File

@@ -0,0 +1,84 @@
# Metabase Writing Style Guide
## Core Principles
Write like you're talking to a colleague. Be conversational, not formal. Get people what they need quickly. Know your audience and match the complexity.
## Tone and Voice
**Do:**
- Use contractions ("can't" not "cannot")
- Say "people" or "companies" instead of "users"
- Be friendly but not peppy
- Acknowledge limitations honestly ("that's on us, not them")
- Jokes and Easter eggs are okay (permit them, don't suggest them)
**Don't:**
- Use exclamation points excessively
- Rely on tired tropes about nerdiness
- Use corporate jargon ("utilize", "offerings", "actionable insights")
- Tell people something is cool (show them instead)
## Structure and Clarity
**Lead with the important stuff:**
- Most important information first
- Lead with the ask, then provide context
- Cut text that adds little value (when in doubt, cut it)
- Each paragraph should have one clear purpose
**Make headings do the work:**
- Convey your actual point, not just the topic
- "Use headings to highlight key points" not "How to write a good heading"
- Use sentence case, no punctuation except question marks
- No links in headings unless entire heading is a link
## Instructions and Examples
**Tell people what to do:**
- Give the action before explaining why
- Put commands in execution order
- Provide context, steps, and reasoning
- Don't describe tasks as "easy" or "simple"
**Answer unasked questions:**
- Include answers to "dumb" questions you had when learning
- Don't literally include the question, just give the answer
## Formatting
**Code vs. UI elements:**
- Backticks for code, variable names, parameters only
- **Bold** for UI elements and labels (e.g., "Click the **Save** button")
- Don't use backticks or quotes for UI elements
## Writing Mechanics
- Limit pronouns when introducing new terms (repeat the term to reinforce it)
- Ampersands only in proper nouns, never as substitute for "and"
## Terminology Preferences
Use familiar terms from tools people already know:
- "Summarize" (like Excel) instead of "aggregate"
- "Take a look at" instead of "reference"
- "Filter" instead of technical database terms when appropriate
## Red Flags
Avoid these patterns:
- Multiple exclamation points
- Linking "here"
- Bullet lists to explain (use prose)
- Numbers that will change (guard against change)
- Describing things as "easy" or "simple"
- Stock photography-worthy content

View File

@@ -0,0 +1,21 @@
## Linting and Formatting
- **Lint:** `yarn lint-eslint-pure`
- Run ESLint on the codebase
- **Format:** `yarn prettier`
- Format code using Prettier
- **Type Check:** `yarn type-check-pure`
- Run TypeScript type checking
## Testing
### JavaScript/TypeScript Tests
- **Test a specific file:** `yarn test-unit-keep-cljs path/to/file.unit.spec.js`
- **Test by pattern:** `yarn test-unit-keep-cljs -t "pattern"`
- Runs tests matching the given pattern
### ClojureScript Tests
- **Test ClojureScript:** `yarn test-cljs`
- Run ClojureScript tests

View File

@@ -0,0 +1,144 @@
---
name: clojure-review
description: Review Clojure and ClojureScript code changes for compliance with Metabase coding standards, style violations, and code quality issues. Use when reviewing pull requests or diffs containing Clojure/ClojureScript code.
allowed-tools: Read, Grep, Bash, Glob
---
# Clojure Code Review Skill
@./../_shared/clojure-style-guide.md
@./../_shared/clojure-commands.md
## Review guidelines
**What to flag:**
- Check compliance with the Metabase Clojure style guide (included above)
- If `CLOJURE_STYLE_GUIDE.adoc` exists in the working directory, also check compliance with the community Clojure style guide
- Flag all style guide violations
**What NOT to post:**
- Do not post comments congratulating someone for trivial changes or for following style guidelines
- Do not post comments confirming things "look good" or telling them they did something correctly
- Only post comments about style violations or potential issues
Example bad code review comments to avoid:
> This TODO comment is properly formatted with author and date - nice work!
> Good addition of limit 1 to the query - this makes the test more efficient without changing its behavior.
> The kondo ignore comment is appropriately placed here
> Test name properly ends with -test as required by the style guide.
**Special cases:**
- Do not post comments about missing parentheses (these will be caught by the linter)
## Quick review checklist
Use this to scan through changes efficiently:
### Naming
- [ ] Descriptive names (no `tbl`, `zs'`)
- [ ] Pure functions named as nouns describing their return value
- [ ] `kebab-case` for all variables and functions
- [ ] Side-effect functions end with `!`
- [ ] No namespace-alias repetition in function names
### Documentation
- [ ] Public vars in `src` or `enterprise/backend/src` have useful docstrings
- [ ] Docstrings use Markdown conventions
- [ ] References use `[[other-var]]` not backticks
- [ ] `TODO` comments include author and date: `;; TODO (Name 1/1/25) -- description`
### Code Organization
- [ ] Everything `^:private` unless used elsewhere
- [ ] No `declare` when avoidable (public functions near end)
- [ ] Functions under 20 lines when possible
- [ ] No blank lines within definition forms (except pairwise constructs in `let`/`cond`)
- [ ] Lines ≤ 120 characters
### Tests
- [ ] Separate `deftest` forms for distinct test cases
- [ ] Pure tests marked `^:parallel`
- [ ] Test names end in `-test` or `-test-<number>`
### Modules
- [ ] Correct module patterns (OSS: `metabase.<module>.*`, EE: `metabase-enterprise.<module>.*`)
- [ ] API endpoints in `<module>.api` namespaces
- [ ] Public API in `<module>.core` with Potemkin
- [ ] No cheating module linters with `:clj-kondo/ignore [:metabase/modules]`
### REST API
- [ ] Response schemas present (`:- <schema>`)
- [ ] Query params use kebab-case, bodies use `snake_case`
- [ ] Routes use singular nouns (e.g., `/api/dashboard/:id`)
- [ ] `GET` has no side effects (except analytics)
- [ ] Malli schemas detailed and complete
- [ ] All new endpoints have tests
### MBQL
- [ ] No raw MBQL manipulation outside `lib`, `lib-be`, or `query-processor` modules
- [ ] Uses Lib and MBQL 5, not legacy MBQL
### Database
- [ ] Model and table names are singular nouns
- [ ] Uses `t2/select-one-fn` instead of selecting full rows for one column
- [ ] Logic in Toucan methods, not helper functions
### Drivers
- [ ] New multimethods documented in `docs/developers-guide/driver-changelog.md`
- [ ] Passes `driver` argument to other driver methods (no hardcoded driver names)
- [ ] Minimal logic in `read-column-thunk`
### Miscellaneous
- [ ] Example data is bird-themed when possible
- [ ] Kondo linter suppressions use proper format (not `#_:clj-kondo/ignore` keyword form)
## Pattern matching table
Quick scan for common issues:
| Pattern | Issue |
| -------------------------------------------- | ----------------------------------------------------------- |
| `calculate-age`, `get-user` | Pure functions should be nouns: `age`, `user` |
| `update-db`, `save-model` | Missing `!` for side effects: `update-db!`, `save-model!` |
| `snake_case_var` | Should use kebab-case |
| Public var without docstring | Add docstring explaining purpose |
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 1/1/25) -- description` |
| `(defn foo ...)` in namespace used elsewhere | Should be `(defn ^:private foo ...)` |
| Function > 20 lines | Consider breaking up into smaller functions |
| `/api/dashboards/:id` | Use singular: `/api/dashboard/:id` |
| Query params with `snake_case` | Use kebab-case for query params |
| New API endpoint without tests | Add tests for the endpoint |
## Feedback format examples
**For style violations:**
> This pure function should be named as a noun describing its return value. Consider `user` instead of `get-user`.
**For missing documentation:**
> This public var needs a docstring explaining its purpose, inputs, and outputs.
**For organization issues:**
> This function is only used in this namespace, so it should be marked `^:private`.
**For API conventions:**
> Query parameters should use kebab-case. Change `user_id` to `user-id`.

View File

@@ -0,0 +1,59 @@
---
name: clojure-write
description: Guide Clojure and ClojureScript development using REPL-driven workflow, coding conventions, and best practices. Use when writing, developing, or refactoring Clojure/ClojureScript code.
---
# Clojure Development Skill
@./../_shared/development-workflow.md
@./../_shared/clojure-style-guide.md
@./../_shared/clojure-commands.md
## REPL-Driven Development Workflow
- Start with small, fundamental functions:
- Identify the core features or functionalities required for your task.
- Break each feature down into the smallest, most basic functions that can be developed and tested independently.
- Write and test in the REPL:
- Write the code for each small function directly in the REPL (Read-Eval-Print Loop).
- Test it thoroughly with a variety of inputs, including typical use cases and relevant edge cases, to ensure it
behaves as expected.
- Integrate into source code:
- Once a function works correctly in the REPL, move it from the REPL environment into your source code files (e.g.,
within appropriate namespaces).
- Gradually increase complexity:
- Build upon tested, basic functions to create more complex functions or components.
- Compose smaller functions together, testing each new composition in the REPL to verify correctness step by step.
- Ensure dependency testing:
- Make sure every function is fully tested in the REPL before it is depended upon by other functions.
- This ensures that each layer of your application is reliable before you build on it.
- Use the REPL fully:
- Use the REPL as your primary tool to experiment with different approaches, iterate quickly, and get immediate
feedback on your code.
- Follow functional programming principles:
- Keep functions small, focused, and composable.
- Use Clojure's functional programming features—like immutability, higher-order functions, and the standard
library—to write concise, effective code.
## How to Evaluate Code
### Bottom-up Dev Loop
1. Write code into a file.
2. Evaluate the file's namespace and make sure it loads correctly with:
```
./bin/mage -repl --namespace metabase.app-db.connection
```
3. Call functions in the namespace with test inputs, and observe that the outputs are correct
Feel free to copy these REPL session trials into actual test cases using `deftest` and `is`.
4. Once you know these functions are good, return to 1, and compose them into the task that you need to build.
## Critical Rules for Editing
- Be careful with parentheses counts when editing Clojure code
- After EVERY change to Clojure code, verify readability with `-check-readable`
- End all files with a newline
- When editing tabular code, where the columns line up, try to keep them aligned
- Spaces on a line with nothing after it is not allowed

View File

@@ -0,0 +1,168 @@
---
name: docs-review
description: Review documentation changes for compliance with the Metabase writing style guide. Use when reviewing pull requests, files, or diffs containing documentation markdown files.
allowed-tools: Read, Grep, Bash, Glob
---
# Documentation Review Skill
@./../_shared/metabase-style-guide.md
## Review mode detection
**IMPORTANT: Before starting the review, determine which mode to use:**
1. **PR review mode**: If the `mcp__github__create_pending_pull_request_review` tool is available, you are reviewing a GitHub PR
- Use the pending review workflow to post all issues as one cohesive review
- Follow the workflow steps in "PR review mode format" below
2. **Local review mode**: If the MCP tool is NOT available, output issues in the conversation
- Format all issues in a numbered markdown list (as described in "Feedback format" below)
## Review process
1. **Detect review mode** - Check if `mcp__github__create_pending_pull_request_review` is available
2. Read the changes through once to understand intent
3. Check all issues that violate style guide or significantly impact readability
4. Only flag issues worth mentioning - if it won't make a material difference to the reader, skip it
5. **REQUIRED: Number ALL feedback sequentially** - Start from Issue 1 and increment for each issue found
## Review checklist
Run through the diff looking for these issues:
**Tone and voice:**
- [ ] Formal/corporate language ("utilize" not "use", "offerings", etc.)
- [ ] "Users" instead of "people" or "companies"
- [ ] Excessive exclamation points or overly peppy tone
- [ ] Telling readers something is cool instead of showing them
**Structure and clarity:**
- [ ] Important information buried instead of leading
- [ ] Verbose text that adds little value
- [ ] Paragraphs without clear purpose
- [ ] Vague headings that don't convey the point
- [ ] Instructions explain "why" before telling "what to do"
- [ ] Tasks described as "easy" or "simple"
**Links and references:**
- [ ] Linking the word "here" instead of descriptive text
- [ ] Links in headings (unless entire heading is a link)
**Formatting:**
- [ ] Backticks on UI elements (should use **bold**)
- [ ] Backticks used as quotation marks
- [ ] Ampersands as "and" substitute (except proper nouns)
- [ ] Inconsistent list formatting
**Code and examples:**
- [ ] Code examples that don't work or would error
- [ ] Commands not in execution order
- [ ] Full-width screenshots instead of scoped UI elements
- [ ] Excessive or unnecessary images
**Sentence construction:**
- [ ] Overuse of pronouns when introducing new terms
## Quick scan table
| Pattern | Issue |
| ----------------------------- | --------------------------------------------- |
| `Button name` or `UI element` | Should use **bold** not backticks |
| we can do X, our feature | Should be "Metabase" or "it" |
| click here, read more here | Need descriptive link text |
| easy, simple, just | Remove condescending qualifiers |
| users | Should be "people" or "companies" if possible |
## Feedback format
**MANDATORY REQUIREMENT: Every single issue MUST be numbered sequentially starting from Issue 1.**
This numbered format is NON-NEGOTIABLE. It allows users to efficiently reference specific issues (e.g., "fix issues 1, 3, and 5") and track which feedback has been addressed.
### Local review mode format
When outputting issues in the conversation (local mode), use this format:
```markdown
## Issues
**Issue 1: [Brief title]**
Line X: Succinct description of the issue
[code or example]
Suggested fix or succinct explanation
**Issue 2: [Brief title]**
Line Y: Description of the issue
Suggested fix or explanation
**Issue 3: [Brief title]**
...
```
**Examples:**
> **Issue 1: Backticks on UI elements**
> Line 42: This uses backticks for the UI element. Use **bold** instead: **Filter** not `Filter`.
> **Issue 2: Formal tone**
> Line 15: This could be more conversational. Consider: "You can't..." instead of "You cannot..."
> **Issue 3: Vague heading**
> Line 8: The heading could be more specific. Try stating the point directly: "Run migrations before upgrading" vs "Upgrade process"
### PR review mode format
When posting to GitHub (PR mode), use the **pending review workflow**:
**Workflow steps:**
1. **Start a review**: Use `mcp__github__create_pending_pull_request_review` to begin a pending review
- This creates a draft review that won't be visible until submitted
2. **Get diff information**: Use `mcp__github__get_pull_request_diff` to understand the code changes and line numbers
- This helps you determine the correct file paths and line numbers for comments
3. **Identify ALL issues**: Read through all changes and identify every issue worth mentioning
- Collect all issues before posting any comments
- Number them sequentially (Issue 1, Issue 2, Issue 3, etc.)
4. **Add review comments**: Use `mcp__github__add_pull_request_review_comment_to_pending_review` for each issue
- **CRITICAL**: Post ALL comments in a SINGLE response using multiple tool calls in parallel
- Each comment should reference a specific file path and line number from the diff
- Start each comment body with `**Issue N: [Brief title]**`
- Include the description and suggested fix
5. **Submit the review**: Use `mcp__github__submit_pending_pull_request_review` to publish all comments at once
- Use event type `"COMMENT"` (NOT "REQUEST_CHANGES") to make it non-blocking
- **Do NOT include a body message** - Leave the body empty or omit it entirely
- All comments will appear together as one cohesive review
**Comment format example:**
```
**Issue 1: Backticks on UI elements**
This uses backticks for the UI element. Use **bold** instead: **Filter** not `Filter`.
```
**IMPORTANT**:
- Each issue gets its own review comment attached to the pending review
- Number ALL comments sequentially (Issue 1, Issue 2, Issue 3, etc.)
- Always start the comment body with `**Issue N: [Brief title]**`
- **MUST add all comments in parallel in a single response** - Do NOT add them one after another in separate responses
- Do NOT output a summary message to the conversation - only post GitHub review comments
- When submitting the review, do NOT include a body parameter (or leave it empty) to avoid cluttering the PR with summary text
- The review will appear as a single review with multiple comments when submitted
## Final check
1. Remove any issues from your assessment that won't make a material difference to the reader if addressed. Only flag issues worth the author's time to fix.
2. **Verify all issues are numbered sequentially** starting from Issue 1 with no gaps in numbering.
3. Confirm the format exactly matches: `**Issue N: [Brief title]**` where N is the issue number.
4. **In PR mode**: Verify each issue was posted as a separate GitHub comment (not output to conversation).

View File

@@ -0,0 +1,96 @@
---
name: docs-write
description: Write documentation following Metabase's conversational, clear, and user-focused style. Use when creating or editing documentation files (markdown, MDX, etc.).
allowed-tools: Read, Write, Grep, Bash, Glob
---
# Documentation Writing Skill
@./../_shared/metabase-style-guide.md
## When writing documentation
### Start here
1. **Who is this for?** Match complexity to audience. Don't oversimplify hard things or overcomplicate simple ones.
2. **What do they need?** Get them to the answer fast. Nobody wants to be in docs longer than necessary.
3. **What did you struggle with?** Those common questions you had when learning? Answer them (without literally including the question).
### Writing process
**Draft:**
- Write out the steps/explanation as you'd tell a colleague
- Lead with what to do, then explain why
- Use headings that state your point: "Set SAML before adding users" not "SAML configuration timing"
**Edit:**
- Read aloud. Does it sound like you talking? If it's too formal, simplify.
- Cut anything that doesn't directly help the reader
- Check each paragraph has one clear purpose
- Verify examples actually work (don't give examples that error)
**Polish:**
- Make links descriptive (never "here")
- Backticks only for code/variables, **bold** for UI elements
- American spelling, serial commas
- Keep images minimal and scoped tight
**Format:**
- Run prettier on the file after making edits: `yarn prettier --write <file-path>`
- This ensures consistent formatting across all documentation
### Common patterns
**Instructions:**
```markdown
Run:
\`\`\`
command-to-run
\`\`\`
Then:
\`\`\`
next-command
\`\`\`
This ensures you're getting the latest changes.
```
Not: "(remember to run X before Y...)" buried in a paragraph.
**Headings:**
- "Use environment variables for configuration" ✅
- "Environment variables" ❌ (too vague)
- "How to use environment variables for configuration" ❌ (too wordy)
**Links:**
- "Check out the [SAML documentation](link)" ✅
- "Read the docs [here](link)" ❌
### Watch out for
- Describing tasks as "easy" (you don't know the reader's context)
- Using "we" when talking about Metabase features (use "Metabase" or "it")
- Formal language: "utilize", "reference", "offerings"
- Too peppy: multiple exclamation points
- Burying the action in explanation
- Code examples that don't work
- Numbers that will become outdated
### Quick reference
| Write This | Not This |
| -------------------------- | ------------------ |
| people, companies | users |
| summarize | aggregate |
| take a look at | reference |
| can't, don't | cannot, do not |
| **Filter** button | \`Filter\` button |
| Check out [the docs](link) | Click [here](link) |

View File

@@ -0,0 +1,19 @@
---
name: typescript-review
description: Review TypeScript and JavaScript code changes for compliance with Metabase coding standards, style violations, and code quality issues. Use when reviewing pull requests or diffs containing TypeScript/JavaScript code.
allowed-tools: Read, Grep, Bash, Glob
---
# TypeScript/JavaScript Code Review Skill
@./../_shared/typescript-commands.md
## Code Review Guidelines
Review pull requests with a focus on:
- Compliance with project coding standards and conventions
- Code quality and best practices
- Clear and correct JSDoc comments
- Type safety and proper TypeScript usage
- React best practices (when applicable)

View File

@@ -0,0 +1,9 @@
---
name: typescript-write
description: Write TypeScript and JavaScript code following Metabase coding standards and best practices. Use when developing or refactoring TypeScript/JavaScript code.
---
# TypeScript/JavaScript Development Skill
@./../_shared/development-workflow.md
@./../_shared/typescript-commands.md

View File

@@ -208,8 +208,8 @@
auth-identity
{:team "UX West"
:api #{metabase.auth-identity.core
metabase.auth-identity.init}
:api #{metabase.auth-identity.core
metabase.auth-identity.init}
:uses #{channel
events
login-history
@@ -989,7 +989,7 @@
metabase.queries.core
metabase.queries.init
metabase.queries.models.query ; TODO FIXME
metabase.queries.schema} ; TODO FIXME
metabase.queries.schema} ; TODO FIXME
:uses #{analytics
analyze
api
@@ -1118,7 +1118,7 @@
:uses #{api
config
embedding
permissions ; FIXME
permissions ; FIXME
session
settings
users
@@ -1757,7 +1757,7 @@
enterprise/auth-identity
{:team "UX West"
:api #{}
:api #{}
:uses #{premium-features}}
enterprise/auth-provider
@@ -2201,6 +2201,19 @@
settings
util}}
enterprise/support-access-grants
{:team "UX West"
:api #{metabase-enterprise.support-access-grants.api
metabase-enterprise.support-access-grants.init}
:uses #{api
auth-identity
events
models
request
settings
system
util}}
enterprise/transforms
{:team "Querying"
:api #{metabase-enterprise.transforms.api

View File

@@ -344,5 +344,11 @@ module.exports = {
"import/no-commonjs": "off",
},
},
{
files: ["**/*.stories.tsx", "**/preview.tsx"],
rules: {
"import/no-default-export": "off",
},
},
],
};

View File

@@ -13,6 +13,9 @@ outputs:
eslint-cache-key:
description: "The cache key for Eslint cache"
value: ${{ steps.keys-factory.outputs.eslint-cache-key }}
cypress-cache-key:
description: "The cache key for Cypress cache"
value: ${{ steps.keys-factory.outputs.cypress-cache-key }}
runs:
using: "composite"
@@ -23,10 +26,12 @@ runs:
M2_CACHE_PREFIX='M2'
NODE_CACHE_PREFIX='node-modules'
ESLINT_CACHE_PREFIX='eslint'
CYPRESS_CACHE_PREFIX='cypress'
WEEK_OF_YEAR=$(/bin/date "+%G-%V")
CACHE_SUFFIX="${{ runner.os }}-$WEEK_OF_YEAR"
echo "m2-cache-key=$M2_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
echo "node-cache-key=$NODE_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
echo "eslint-cache-key=$ESLINT_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
echo "cypress-cache-key=$CYPRESS_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
shell: bash

View File

@@ -25,11 +25,6 @@ runs:
curl -O https://download.clojure.org/install/linux-install-${{ inputs.clojure-version }}.sh &&
sudo bash ./linux-install-${{ inputs.clojure-version }}.sh
- name: Fetch CI config
shell: bash
run: |
curl -o ci-test-config.json https://raw.githubusercontent.com/metabase/metabase/refs/heads/master/ci-test-config.json
- name: Get M2 cache key
uses: ./.github/actions/get-cache-keys
id: get-cache-keys

View File

@@ -22,16 +22,16 @@ runs:
chrome-version: 1097615
id: setup-chrome
- name: Get current week of year
id: get-date
run: |
echo "week-of-year=$(/bin/date "+%G-%V")" >> $GITHUB_OUTPUT
shell: bash
- name: Get cache keys
uses: ./.github/actions/get-cache-keys
id: get-cache-keys
- name: Get Cypress cache
uses: actions/cache@v4
uses: actions/cache/restore@v4
with:
path: ~/.cache/Cypress
key: ${{ runner.os }}-Cypress-${{ steps.get-date.outputs.week-of-year }}
key: ${{ steps.get-cache-keys.outputs.cypress-cache-key }}
- name: Ensure that Cypress executable is ready
run: |
yarn cypress install

View File

@@ -6,7 +6,7 @@ inputs:
apply-patches:
description: "Whether to patch frontend dependencies with patch-package."
required: true
default: 'true'
default: "true"
runs:
using: "composite"
steps:
@@ -28,11 +28,21 @@ runs:
~/.gitlibs
key: ${{ steps.get-cache-keys.outputs.m2-cache-key }}
- name: Get yarn cache directory
id: yarn-cache-dir
run: |
YARN_CACHE_DIR=$(yarn cache dir)
echo "Yarn cache directory: $YARN_CACHE_DIR"
echo "dir=$YARN_CACHE_DIR" >> $GITHUB_OUTPUT
shell: bash
- name: Restore node_modules cache
uses: actions/cache/restore@v4
id: node-modules-cache
with:
path: node_modules
path: |
${{ steps.yarn-cache-dir.outputs.dir }}
node_modules
key: ${{ steps.get-cache-keys.outputs.node-cache-key }}
# postinstall lifecycle hook can be dangerous in CI so we only run it locally (see package.json)

View File

@@ -10,21 +10,54 @@ inputs:
required: true
default: false
outputs:
quarantine-skipped:
description: Whether the driver test was skipped due to quarantine
value: ${{ steps.check-driver.outputs.skip }}
runs:
using: "composite"
steps:
- name: Check if driver is ignored
id: check-driver
shell: bash
run: |
# If DRIVERS env var is not set, proceed with tests
if [ -z "$DRIVERS" ]; then
echo "No DRIVERS env var set. Proceeding with tests."
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
fi
# Fetch CI config and check if driver should be skipped
curl -sSf -o ci-test-config.json https://raw.githubusercontent.com/metabase/ci-test-config/refs/heads/master/ci-test-config.json || true
# Check if the driver is in the ignored list (gracefully handle missing/invalid config)
IS_IGNORED=$(jq --arg driver "$DRIVERS" '.ignored.drivers // [] | contains([$driver])' ci-test-config.json 2>/dev/null || echo "false")
if [ "$IS_IGNORED" = "true" ]; then
echo "⚠️ Driver '$DRIVERS' is in the ignored list. Skipping tests."
echo "View the ignored list here: https://github.com/metabase/ci-test-config/blob/master/ci-test-config.json"
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "Driver '$DRIVERS' is not ignored. Proceeding with tests."
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Prepare back-end environment
if: steps.check-driver.outputs.skip != 'true'
uses: ./.github/actions/prepare-backend
- name: Prepare front-end environment
if: ${{ steps.check-driver.outputs.skip != 'true' && inputs.build-static-viz == 'true' }}
uses: ./.github/actions/prepare-frontend
if: ${{ inputs.build-static-viz == 'true' }}
- name: Build static viz frontend
if: ${{ steps.check-driver.outputs.skip != 'true' && inputs.build-static-viz == 'true' }}
run: yarn build-static-viz
shell: bash
if: ${{ inputs.build-static-viz == 'true' }}
env:
MB_EDITION: ee
- name: Test database driver
if: steps.check-driver.outputs.skip != 'true'
run: clojure -X:dev:ci:ee:ee-dev:drivers:drivers-dev:test ${{ inputs.test-args }}
shell: bash
id: run-test
@@ -37,7 +70,7 @@ runs:
# As per the documentation: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context
# test.outcome gives us access to the result before `continue-on-error` overrides it and sets it to 'success'.
# We want test reports even if the test step gets cancelled (e.g. times out), hence we match anything that is not a 'success'.
if: steps.run-test.outcome != 'success'
if: ${{ steps.run-test.outcome != 'success' && steps.check-driver.outputs.skip != 'true' }}
with:
path: "target/junit/**/*_test.xml"
name: JUnit Test Report ${{ inputs.junit-name }}
@@ -46,7 +79,7 @@ runs:
list-tests: failed
fail-on-error: false
- name: Upload Logs on Failure
if: always() && steps.run-test.outcome != 'success'
if: always() && steps.run-test.outcome != 'success' && steps.check-driver.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: test-logs-${{ github.job }}

View File

@@ -19,7 +19,7 @@ frontend_ci: &frontend_ci
frontend_embedding_sdk_ci: &frontend_embedding_sdk_ci
- ".github/actions/prepare-frontend/**"
- ".github/actions/prepare-backend/**"
- ".github/workflows/*embedding-sdk*.yml"
- ".github/workflows/embedding-sdk*.yml"
frontend_configs: &frontend_configs
- "yarn.lock"
@@ -130,7 +130,7 @@ e2e_specs: &e2e_specs
e2e_embedding_sdk:
- *frontend_embedding_sdk_ci
- *sources
- "e2e/component/**/*.cy.*.{js,ts}"
- "e2e/test-component/**/*.cy.*.{js,jsx,ts,tsx}"
e2e_embedding_sdk_compatibility:
- *default

3
.github/team.json vendored
View File

@@ -106,7 +106,8 @@
"chodorowicz",
"kamilmielnik",
"stasgavrylov",
"uladzimirdev"
"uladzimirdev",
"egorgrushin"
]
}
]

View File

@@ -48,13 +48,11 @@ jobs:
- name: Enterprise Tests
edition: ee
test-group-name: ee
build-static-viz: 'false'
test-args: >-
:only '"enterprise/backend/test"'
- name: EE App DB Tests (Part 1)
edition: ee
test-group-name: ee-app-db-1
build-static-viz: 'true'
test-args: >-
:only '["test" ".clj-kondo/test"]'
:partition/total 2
@@ -62,7 +60,6 @@ jobs:
- name: EE App DB Tests (Part 2)
edition: ee
test-group-name: ee-app-db-2
build-static-viz: 'true'
test-args: >-
:only '["test" ".clj-kondo/test"]'
:partition/total 2
@@ -70,7 +67,6 @@ jobs:
- name: OSS App DB Tests (Part 1)
edition: oss
test-group-name: oss-app-db-1
build-static-viz: 'true'
test-args: >-
:only '["test" ".clj-kondo/test"]'
:partition/total 2
@@ -78,7 +74,6 @@ jobs:
- name: OSS App DB Tests (Part 2)
edition: oss
test-group-name: oss-app-db-2
build-static-viz: 'true'
test-args: >-
:only '["test" ".clj-kondo/test"]'
:partition/total 2
@@ -103,25 +98,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Prepare front-end environment
if: ${{ matrix.job.build-static-viz == 'true' }}
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
with:
java-version: "${{ matrix.java-version }}"
# Depending on which files changed, we might either have to build static-viz
# from scratch or download the previously built artifact
- name: Build static-viz frontend
if: ${{ matrix.job.build-static-viz == 'true' && !inputs.skip }}
run: yarn build-static-viz
env:
MB_EDITION: ${{ matrix.job.edition }}
- name: Download Static Viz Bundle Artifact
if: ${{ matrix.job.build-static-viz == 'true' && !inputs.skip }}
uses: actions/download-artifact@v4
with:
name: static-viz-bundle-${{ github.sha }}
path: resources/frontend_client/app/dist
- name: "Test Java ${{ matrix.java-version }} ${{ matrix.job.name }}"
id: run-java-tests

View File

@@ -30,8 +30,8 @@ name: Cache Generator
on:
schedule:
- cron: '0 3 * * 1' # Mondays at 03:00 UTC
workflow_dispatch: # manually update the cache
- cron: "0 3 * * 1" # Mondays at 03:00 UTC
workflow_dispatch: # manually update the cache
concurrency:
group: cache-generator
@@ -49,6 +49,7 @@ jobs:
m2-cache-key: ${{ steps.get-cache-keys.outputs.m2-cache-key }}
node-cache-key: ${{ steps.get-cache-keys.outputs.node-cache-key }}
eslint-cache-key: ${{ steps.get-cache-keys.outputs.eslint-cache-key }}
cypress-cache-key: ${{ steps.get-cache-keys.outputs.cypress-cache-key }}
generate-m2-cache:
runs-on: ubuntu-22.04
@@ -63,7 +64,7 @@ jobs:
# Add or remove aliases to affect the cache size and its contents.
# Adjust to taste, then redeploy this workflow.
- name: Pre-resolve dependencies
- name: Pre-resolve Clojure dependencies
run: clojure -A:build:dev:ee:ee-dev:drivers:drivers-dev:cljs -P
- name: Update M2 cache
@@ -81,6 +82,7 @@ jobs:
env:
NODE_CACHE_KEY: ${{ needs.get-cache-keys.outputs.node-cache-key }}
ESLINT_CACHE_KEY: ${{ needs.get-cache-keys.outputs.eslint-cache-key }}
CYPRESS_CACHE_KEY: ${{ needs.get-cache-keys.outputs.cypress-cache-key }}
steps:
- uses: actions/checkout@v4
# `prepare-frontend` action (1) prepares Node.js and (2) fetches the node_modules cache if it exists.
@@ -92,16 +94,39 @@ jobs:
- name: Install front-end dependencies
uses: ./.github/actions/prepare-frontend
with:
apply-patches: 'false'
apply-patches: "false"
- name: Get yarn cache directory
id: yarn-cache-dir
run: |
YARN_CACHE_DIR=$(yarn cache dir)
echo "Yarn cache directory: $YARN_CACHE_DIR"
echo "dir=$YARN_CACHE_DIR" >> $GITHUB_OUTPUT
- name: Ensure that Cypress executable is ready
run: |
yarn cypress install
yarn cypress cache path
yarn cypress cache list
yarn cypress verify
shell: bash
- name: Update node_modules cache
uses: ./.github/actions/update-cache
with:
path: node_modules
path: |
${{ steps.yarn-cache-dir.outputs.dir }}
node_modules
key: ${{ env.NODE_CACHE_KEY }}
- name: Update eslint cache
uses: ./.github/actions/update-cache
with:
path: .eslintcache
path: ${{ github.workspace}}/.eslintcache
key: ${{ env.ESLINT_CACHE_KEY }}
- name: Update Cypress cache
uses: ./.github/actions/update-cache
with:
path: ~/.cache/Cypress
key: ${{ env.CYPRESS_CACHE_KEY }}

View File

@@ -159,15 +159,15 @@ jobs:
1. **Analyze the issue description and comments** - Understand what the issue is describing
a. Pay particular attention to reproduction steps and if there's an agreed upon fix.
2. **Search the codebase** - Use Grep and Glob tools to find relevant files
3. **Implement a fix** - Follow existing code patterns and conventions
4. **Test the fix** - Run appropriate tests using the commands in CLAUDE.md:
- JavaScript/TypeScript: `yarn test-unit path/to/file.unit.spec.js`
- Clojure: `clojure -X:dev:test :only namespace/test-name`
- Lint: `yarn lint-eslint-pure` for JS/TS, `./bin/mage kondo <file>` for Clojure
3. **Implement a fix** - Use the appropriate write skill based on the file types:
- Use clojure-write for Clojure/ClojureScript files
- Use typescript-write for TypeScript/JavaScript files
- Follow the coding standards and patterns in each skill
4. **Test the fix** - Run appropriate tests as specified in the skills
5. **Verify the fix** - Ensure tests pass and code follows conventions
## Important Guidelines
- Follow the development patterns in CLAUDE.md
- Use the appropriate write skill for the language you're working in
- Write failing tests first, then fix them
- Work in small, testable increments
- Don't commit changes - leave that for the workflow

View File

@@ -79,17 +79,14 @@ jobs:
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Review this pull request with a focus on:
Review this pull request for compliance with project coding standards and conventions.
- Compliance with project coding standards and conventions
- Code quality and best practices
- Clear and correct docstrings
- Clear and correct comments explaining what the code is doing
**Use the appropriate review skill based on the file types changed:**
- Use clojure-review for Clojure/ClojureScript files
- Use typescript-review for TypeScript/JavaScript files
- Follow the review guidelines and checklists in each skill
DO NOT post any comments confirming that something is correct or congratulating someone for following our
guidelines -- only post comments for style violations or concerns.
Follow additional guidelines in the "Code Review Guidelines" section of CLAUDE.md .
DO NOT post any comments confirming that something is correct - only post comments for style violations or concerns.
Note: The PR branch is already checked out in the current working directory.

100
.github/workflows/claude-docs-review.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: "Claude Docs Review"
on:
pull_request:
types:
- opened
- synchronize
- reopened
paths:
- 'docs/**/*.md'
- 'docs/**/*.mdx'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: number
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-claude-docs-review
cancel-in-progress: true
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
# Make sure the value of GITHUB_TOKEN will not be persisted in repo's config
persist-credentials: false
- name: Generate GitHub App Token for Claude
uses: actions/create-github-app-token@v1
id: claude-app-token
with:
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
permission-contents: read # Required for reading repository files and PR diffs
permission-pull-requests: write # Required for creating inline comments and PR comments
- name: Delete old Claude docs review comments
env:
GH_TOKEN: ${{ steps.claude-app-token.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
shell: bash
run: |
# Delete pending reviews first (these can be deleted entirely)
pending_review_ids=$(gh api "/repos/$REPO/pulls/$PR_NUMBER/reviews" --jq '.[] | select(.user.login=="metabase-bot[bot]" and .state=="PENDING") | .id')
if [ -n "$pending_review_ids" ]; then
echo "Deleting pending reviews: $pending_review_ids"
for id in $pending_review_ids; do
gh api -X DELETE "/repos/$REPO/pulls/$PR_NUMBER/reviews/$id"
echo "Deleted pending review $id"
done
fi
# Delete individual inline comments from submitted reviews (can't delete the review itself)
old_line_comment_ids=$(gh api "/repos/$REPO/pulls/$PR_NUMBER/comments" --jq '.[] | select(.user.login=="metabase-bot[bot]") | .id')
if [ -n "$old_line_comment_ids" ]; then
echo "Deleting line comments: $old_line_comment_ids"
for id in $old_line_comment_ids; do
gh api -X DELETE "/repos/$REPO/pulls/comments/$id"
echo "Deleted line comment $id"
done
fi
# Delete top-level review comments
old_top_level_comment_ids=$(gh api "/repos/$REPO/issues/$PR_NUMBER/comments" --jq '.[] | select(.user.login=="metabase-bot[bot]") | .id')
if [ -n "$old_top_level_comment_ids" ]; then
echo "Deleting top-level comments: $old_top_level_comment_ids"
for id in $old_top_level_comment_ids; do
gh api -X DELETE "/repos/$REPO/issues/comments/$id"
echo "Deleted top-level comment $id"
done
fi
- uses: anthropics/claude-code-action@v1
timeout-minutes: 10
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.claude-app-token.outputs.token }}
show_full_output: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
Review the documentation changes in this pull request.
Use the docs-review skill to review all changed documentation files for compliance with the Metabase writing style guide.
DO NOT post any comments confirming that something is correct - only post comments for style violations or concerns.
claude_args: |
--allowedTools "Skill,mcp__github__get_pull_request,mcp__github__get_pull_request_files,mcp__github__create_pending_pull_request_review,mcp__github__get_pull_request_diff,mcp__github__add_comment_to_pending_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__delete_pending_pull_request_review,mcp__github__submit_pending_pull_request_review"

View File

@@ -2,8 +2,6 @@ name: Check doc links
on:
pull_request:
paths:
- "docs/**"
workflow_dispatch:
jobs:
@@ -15,7 +13,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
sparse-checkout: |
docs
@@ -29,7 +27,7 @@ jobs:
- name: Run link checker
id: link-check
uses: lycheeverse/lychee-action@v2
uses: lycheeverse/lychee-action@v2.3.0
with:
fail: true
args: docs --config ./.lychee/config.toml --offline

View File

@@ -8,7 +8,7 @@ on:
target_branch:
description: "Target branch for documentation update"
required: false
default: "release-x.56.x"
default: "release-x.57.x"
type: string
jobs:
@@ -16,6 +16,6 @@ jobs:
name: Call documentation update for release branch
uses: ./.github/workflows/docs-generate-base.yml
with:
target_branch: ${{ inputs.target_branch || 'release-x.56.x' }}
target_branch: ${{ inputs.target_branch || 'release-x.57.x' }}
secrets:
automation_token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}

View File

@@ -27,21 +27,6 @@ jobs:
# Fetch the base branch to ensure its history is available for diffing
git fetch --depth=1 origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }}
# Only run this if it's a pull request and the base ref exists
- name: Get Date
id: get-date
shell: bash
run: |
echo "date=$(/bin/date -u "+%Y%m%d")" >> $GITHUB_OUTPUT
- name: Restore Babashka cache
uses: actions/cache@v4
with:
path: |
~/.m2
~/.deps.clj
restore-keys: |
mage-drivers-
key: >-
mage-drivers-${{ steps.get-date.outputs.date }}
- name: Check if driver tests should run
id: check-branch-and-files-changed
run: |
@@ -82,6 +67,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test Athena driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-athena-ee'
@@ -90,7 +76,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -144,7 +130,7 @@ jobs:
test-args: ${{ matrix.job.test-args }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -215,7 +201,7 @@ jobs:
test-args: ${{ matrix.job.test-args }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -250,6 +236,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test Druid driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-druid-ee'
@@ -258,7 +245,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -287,6 +274,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test Druid JDBC driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-druid-jdbc-ee'
@@ -295,7 +283,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -324,6 +312,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test Databricks driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-databricks-ee'
@@ -332,7 +321,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -362,6 +351,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test Databricks driver with multi-catalog
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-databricks-multi-catalog-ee'
@@ -370,7 +360,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -399,7 +389,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -528,7 +518,7 @@ jobs:
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -597,7 +587,7 @@ jobs:
test-args: ${{ matrix.job.test-args }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -660,6 +650,7 @@ jobs:
curl https://raw.githubusercontent.com/metabase/metabase-qa/master/dbs/mongo/certificates/metaca.crt \
-o ./test_resources/ssl/mongo/metaca.crt
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: ${{ matrix.version.junit-name }}
@@ -668,7 +659,7 @@ jobs:
:only-tags [:mb/driver-tests]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -695,6 +686,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test MongoDB driver (Sharded cluster)
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-mongo-sharded-cluster-ee'
@@ -702,7 +694,7 @@ jobs:
:only metabase.driver.mongo.sharded-cluster-test
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -767,6 +759,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: ${{ matrix.version.junit-name }}
@@ -775,7 +768,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -878,7 +871,7 @@ jobs:
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -946,6 +939,7 @@ jobs:
-file /tmp/presto-ssl-ca.der
-trustcacerts
- name: Test Presto JDBC driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-presto-jdbc-ee'
@@ -954,7 +948,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -1017,7 +1011,7 @@ jobs:
${{ matrix.job.test-args }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: ${{ !matrix.job.skip && !cancelled() }}
if: ${{ !matrix.job.skip && !cancelled() && steps.test-driver.outputs.quarantine-skipped != 'true' }}
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -1034,7 +1028,8 @@ jobs:
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-snowflake-ee:
if: ${{ !inputs.skip }}
needs: determine-driver-skips
if: ${{ !inputs.skip && !(needs.determine-driver-skips.outputs.skip == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -1086,7 +1081,7 @@ jobs:
test-args: ${{ matrix.job.test-args }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -1119,6 +1114,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test Spark driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-sparksql-ee'
@@ -1127,7 +1123,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -1149,6 +1145,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Test SQLite driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-sqlite-ee'
@@ -1157,7 +1154,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -1222,7 +1219,7 @@ jobs:
test-args: ${{ matrix.job.test-args }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
@@ -1329,6 +1326,7 @@ jobs:
- name: Make plugins directory
run: mkdir plugins
- name: Test Vertica driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-vertica-ee'
@@ -1337,7 +1335,7 @@ jobs:
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
if: always() && steps.test-driver.outputs.quarantine-skipped != 'true'
with:
input-path: ./target/junit/
output-name: ${{ github.job }}

View File

@@ -1,185 +0,0 @@
# This workflow does the backward compatibility tests
# between the Embedding SDK package of version N and Embedding SDK Bundle of version N - 1
# N - 1 is a target of a PR
name: Embedding SDK backward-compatibility E2E Component Tests
on:
workflow_call:
inputs:
cached-embedding-sdk-dist-artifact-name:
required: true
type: string
jobs:
files-changed:
name: "SDK backward-compatibility E2E > Check which files changed"
runs-on: ubuntu-22.04
timeout-minutes: 3
outputs:
e2e_embedding_sdk: ${{ steps.changes.outputs.e2e_embedding_sdk }}
steps:
- uses: actions/checkout@v4
- name: Test which files changed
uses: dorny/paths-filter@v3.0.0
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
decide-target-ref:
runs-on: ubuntu-latest
outputs:
target-ref: ${{ steps.get-target-ref.outputs.value }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Decide ref to checkout
id: get-target-ref
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "value=${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
else
echo "value=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
fi
# if this is a test on a release branch, we need to check the build requirements
get-build-requirements:
name: "SDK backward-compatibility E2E > [Backport only] Get build requirements"
needs: [decide-target-ref]
if: |
!cancelled() &&
contains(github.base_ref || github.ref, 'release-x')
runs-on: ubuntu-22.04
timeout-minutes: 10
outputs:
java_version: ${{ fromJson(steps.dependencies.outputs.result).java_version }}
node_version: ${{ fromJson(steps.dependencies.outputs.result).node_version }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide-target-ref.outputs.target-ref }}
sparse-checkout: release
- name: Prepare build scripts
run: cd ${{ github.workspace }}/release && yarn && yarn build
- name: Get build dependencies
uses: actions/github-script@v7
id: dependencies
with:
script: | # js
const {
getBuildRequirements,
getVersionFromReleaseBranch,
} = require('${{ github.workspace }}/release/dist/index.cjs');
const targetBranchName = '${{ github.base_ref || github.ref }}';
const version = getVersionFromReleaseBranch(targetBranchName);
const requirements = getBuildRequirements(version);
return {
java_version: requirements.java,
node_version: requirements.node,
};
e2e-tests:
name: "SDK backward-compatibility E2E > Run e2e tests"
needs: [files-changed, decide-target-ref, get-build-requirements]
if: |
!cancelled() &&
needs.files-changed.outputs.e2e_embedding_sdk == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 45
env:
MB_EDITION: ee
DISPLAY: ""
MB_RUN_MODE: "e2e"
METASTORE_DEV_SERVER_URL: ${{ secrets.METASTORE_DEV_SERVER_URL }}
# Any env starting with `CYPRESS_` will be available to all Cypress tests via `Cypress.env()`
# Example: you can get `CYPRESS_FOO` with `Cypress.env("FOO")`
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
CYPRESS_MB_STARTER_CLOUD_TOKEN: ${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }}
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
CYPRESS_MB_PRO_SELF_HOSTED_TOKEN: ${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }}
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title || github.event.head_commit.message || github.event.head.sha }}
TZ: US/Pacific # to make node match the instance tz
CYPRESS_CI: true
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide-target-ref.outputs.target-ref }}
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
with:
java-version: "${{ needs.get-build-requirements.outputs.java_version || 21 }}"
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
with:
node-version: "${{ needs.get-build-requirements.outputs.node_version }}"
- name: Build uberjar with ./bin/build.sh
run: ./bin/build.sh
- name: Retrieve Embedding SDK dist artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.cached-embedding-sdk-dist-artifact-name }}
path: ${{ github.workspace }}/resources/embedding-sdk
- name: Prepare Cypress environment
id: cypress-prep
uses: ./.github/actions/prepare-cypress
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start
- name: Make app db snapshot
run: node e2e/runner/run_cypress_ci.js snapshot --browser ${{ steps.cypress-prep.outputs.chrome-path }}
- name: Run component tests for Embedding SDK
run: |
node e2e/runner/run_cypress_ci.js component \
--browser ${{ steps.cypress-prep.outputs.chrome-path }} \
--env grepTags="-@skip-backward-compatibility",grepOmitFiltered=true
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
output-name: e2e-embedding-sdk
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-recording-embedding-sdk-cross-version
path: |
./cypress
./logs/test.log
if-no-files-found: ignore
- name: Publish Summary
if: failure()
uses: actions/github-script@v7
with:
script: | #js
const {
generateReport,
parseReport,
formatSummary
} = require("./.github/scripts/handle-mochawesome-report.js");
const report = await generateReport();
const results = parseReport(report);
const summary = formatSummary(results);
await core.summary.addRaw(summary).write();

View File

@@ -1,203 +0,0 @@
name: E2E Component Tests for Embedding SDK
on:
workflow_call:
inputs:
cached-embedding-sdk-dist-artifact-name:
required: true
type: string
jobs:
files-changed:
name: "SDK e2e > Check which files changed"
runs-on: ubuntu-22.04
timeout-minutes: 3
outputs:
e2e_embedding_sdk: ${{ steps.changes.outputs.e2e_embedding_sdk }}
steps:
- uses: actions/checkout@v4
- name: Test which files changed
uses: dorny/paths-filter@v3.0.0
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
# if this is a test on a release branch, we need to check the build requirements
get-build-requirements:
name: "SDK e2e > [Backport only] Get build requirements"
if: |
!cancelled() &&
contains(github.base_ref || github.ref, 'release-x')
runs-on: ubuntu-22.04
timeout-minutes: 10
outputs:
java_version: ${{ fromJson(steps.dependencies.outputs.result).java_version }}
node_version: ${{ fromJson(steps.dependencies.outputs.result).node_version }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: release
- name: Prepare build scripts
run: cd ${{ github.workspace }}/release && yarn && yarn build
- name: Get build dependencies
uses: actions/github-script@v7
id: dependencies
with:
script: | # js
const {
getBuildRequirements,
getVersionFromReleaseBranch,
} = require('${{ github.workspace }}/release/dist/index.cjs');
const targetBranchName = '${{ github.base_ref || github.ref }}';
const version = getVersionFromReleaseBranch(targetBranchName);
const requirements = getBuildRequirements(version);
return {
java_version: requirements.java,
node_version: requirements.node,
};
build-uberjar:
name: "SDK e2e > Build uberjar"
needs: [files-changed, get-build-requirements]
if: |
!cancelled() &&
needs.files-changed.outputs.e2e_embedding_sdk == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 25
env:
MB_EDITION: ee
INTERACTIVE: false
# make sure that builds on release branches get licenses, because we use them for releases
SKIP_LICENSES: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
with:
node-version: "${{ needs.get-build-requirements.outputs.node_version }}"
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
with:
java-version: "${{ needs.get-build-requirements.outputs.java_version || 21 }}"
- name: Build uberjar with ./bin/build.sh
run: ./bin/build.sh
- name: Prepare uberjar artifact
uses: ./.github/actions/prepare-uberjar-artifact
with:
name: metabase-ee-${{ github.event.pull_request.head.sha || github.sha }}-uberjar-sdk-tests
e2e-tests:
name: "SDK e2e > Run e2e tests (React ${{ matrix.react-version }})"
needs: [get-build-requirements, build-uberjar]
if: |
!cancelled() &&
needs.build-uberjar.result == 'success'
runs-on: ubuntu-22.04
timeout-minutes: 45
strategy:
matrix:
react-version: [18, 19]
fail-fast: false
env:
MB_EDITION: ee
DISPLAY: ""
MB_RUN_MODE: "e2e"
METASTORE_DEV_SERVER_URL: ${{ secrets.METASTORE_DEV_SERVER_URL }}
# Any env starting with `CYPRESS_` will be available to all Cypress tests via `Cypress.env()`
# Example: you can get `CYPRESS_FOO` with `Cypress.env("FOO")`
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
CYPRESS_MB_STARTER_CLOUD_TOKEN: ${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }}
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
CYPRESS_MB_PRO_SELF_HOSTED_TOKEN: ${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }}
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title || github.event.head_commit.message || github.event.head.sha }}
TZ: US/Pacific # to make node match the instance tz
CYPRESS_CI: true
steps:
- uses: actions/checkout@v4
- name: Retrieve uberjar artifact for ee
uses: actions/download-artifact@v4
with:
name: metabase-ee-${{ github.event.pull_request.head.sha || github.sha }}-uberjar-sdk-tests
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare JDK ${{ needs.get-build-requirements.outputs.java_version || 21 }}
uses: actions/setup-java@v4
with:
java-version: ${{ needs.get-build-requirements.outputs.java_version || 21 }}
distribution: "temurin"
- name: Prepare Cypress environment
id: cypress-prep
uses: ./.github/actions/prepare-cypress
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Retrieve Embedding SDK dist artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.cached-embedding-sdk-dist-artifact-name }}
path: ${{ github.workspace }}/resources/embedding-sdk
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start
- name: Make app db snapshot
run: node e2e/runner/run_cypress_ci.js snapshot --browser ${{ steps.cypress-prep.outputs.chrome-path }}
# Has to be run after the app db snapshot is made, as it is not compatible with React 19
- name: Change React version to ${{ matrix.react-version }}
run: ./bin/embedding-sdk/change-react-version.bash ${{ matrix.react-version }}
- name: Run component tests for Embedding SDK
run: node e2e/runner/run_cypress_ci.js component --browser ${{ steps.cypress-prep.outputs.chrome-path }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
output-name: e2e-embedding-sdk-react-${{ matrix.react-version }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: "SDK e2e > Upload Cypress Artifacts upon failure"
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-recording-embedding-sdk-react-${{ matrix.react-version }}
path: |
./cypress
./logs/test.log
if-no-files-found: ignore
- name: "SDK e2e > Publish Summary"
if: failure()
uses: actions/github-script@v7
with:
script: | #js
const {
generateReport,
parseReport,
formatSummary
} = require("./.github/scripts/handle-mochawesome-report.js");
const report = await generateReport();
const results = parseReport(report);
const summary = formatSummary(results);
await core.summary.addRaw(summary).write();

View File

@@ -37,26 +37,26 @@ on:
default: true
jobs:
determine-suite:
name: Determine test suite
determine-test-type:
name: Determine test type
runs-on: ubuntu-22.04
timeout-minutes: 5
outputs:
suite: ${{ steps.detect.outputs.suite }}
type: ${{ steps.detect.outputs.type }}
steps:
- name: Detect suite from spec path
- name: Detect test type from spec path
id: detect
run: |
SPEC='${{ inputs.spec }}'
if [[ "$SPEC" == e2e/test-component/scenarios/embedding-sdk/* ]]; then
echo "suite=component" >> $GITHUB_OUTPUT
echo "type=component" >> $GITHUB_OUTPUT
else
echo "suite=e2e" >> $GITHUB_OUTPUT
echo "type=e2e" >> $GITHUB_OUTPUT
fi
workflow-summary:
name: Stress test inputs
needs: [determine-suite]
needs: [determine-test-type]
runs-on: ubuntu-22.04
timeout-minutes: 5
steps:
@@ -66,7 +66,7 @@ jobs:
echo '' >> $GITHUB_STEP_SUMMARY
echo '- `branch`: ${{ github.ref_name }}' >> $GITHUB_STEP_SUMMARY
echo '- `spec`: ${{ inputs.spec }}' >> $GITHUB_STEP_SUMMARY
echo '- `test_suite` (inferred): ${{ needs.determine-suite.outputs.suite }}' >> $GITHUB_STEP_SUMMARY
echo '- `test_type` (inferred): ${{ needs.determine-test-type.outputs.type }}' >> $GITHUB_STEP_SUMMARY
echo '- `burn_in`: ${{ inputs.burn_in }}' >> $GITHUB_STEP_SUMMARY
echo '- `grep`: "${{ inputs.grep }}"' >> $GITHUB_STEP_SUMMARY
echo '- `qa_db`: "${{ inputs.qa_db }}"' >> $GITHUB_STEP_SUMMARY
@@ -75,7 +75,7 @@ jobs:
echo 'triggered by: @${{ github.event.sender.login }}' >> $GITHUB_STEP_SUMMARY
stress-test-flake-fix:
needs: [determine-suite]
needs: [determine-test-type]
runs-on: ubuntu-22.04
timeout-minutes: 60
name: Stress test E2E flake fix
@@ -122,12 +122,12 @@ jobs:
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
if: ${{ needs.determine-suite.outputs.suite == 'component' }}
if: ${{ needs.determine-test-type.outputs.type == 'component' }}
uses: ./.github/actions/prepare-backend
with:
m2-cache-key: "cljs"
- name: Build Embedding SDK package
if: ${{ needs.determine-suite.outputs.suite == 'component' }}
if: ${{ needs.determine-test-type.outputs.type == 'component' }}
run: yarn build-embedding-sdk-package
- name: Prepare JDK 21
@@ -148,12 +148,12 @@ jobs:
- name: Stress-test ${{ github.event.inputs.spec }} ${{ github.event.inputs.burn_in }} times
env:
GREP: ${{ github.event.inputs.grep }}
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: |
node e2e/runner/run_cypress_ci.js ${{ needs.determine-suite.outputs.suite }} \
node e2e/runner/run_cypress_ci.js ${{ needs.determine-test-type.outputs.type }} \
--spec '${{ github.event.inputs.spec }}' \
--env burn=${{ github.event.inputs.burn_in }} \
--config-file e2e/support/cypress-stress-test.config.js \
--browser ${{ steps.cypress-prep.outputs.chrome-path }}
--config-file e2e/support/cypress-stress-test.config.js
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v4

View File

@@ -120,6 +120,8 @@ jobs:
run: node e2e/runner/run_cypress_ci.js start
- name: Generate database snapshots
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: node e2e/runner/run_cypress_ci.js snapshot
- name: Run auto-split Cypress tests on ${{ inputs.name }}
@@ -133,13 +135,13 @@ jobs:
# do not write cypress-split summary to github
SPLIT_SUMMARY: false
SPEC_PATH: ${{ env.specs || inputs.specs }}
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
shell: bash
run: |
echo "Running tests with specs: $SPEC_PATH"
node e2e/runner/run_cypress_ci.js e2e \
--env grepTags="-@mongo+-@python+-@OSS+-@skip",grepOmitFiltered=true \
--spec "$SPEC_PATH" \
--browser ${{ steps.cypress-prep.outputs.chrome-path }}
--env grepTags="-@mongo+-@python+-@OSS+-@skip" \
--spec "$SPEC_PATH"
# Trunk Quarantine controls the final state of the run in the CI!
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
# whether to fail or to pass the whole job, based on the quarantine list.
@@ -150,12 +152,12 @@ jobs:
shell: bash
env:
SPEC_PATH: ${{ env.specs || inputs.specs }}
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: |
echo "Running tests with specs: $SPEC_PATH"
node e2e/runner/run_cypress_ci.js e2e \
--env grepTags="${{inputs.tags && format('{0}+-@skip', inputs.tags) || '-@skip'}}",grepOmitFiltered=true \
--spec "$SPEC_PATH" \
--browser ${{ steps.cypress-prep.outputs.chrome-path }}
--env grepTags="${{inputs.tags && format('{0}+-@skip', inputs.tags) || '-@skip'}}" \
--spec "$SPEC_PATH"
# Trunk Quarantine controls the final state of the run in the CI!
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
# whether to fail or to pass the whole job, based on the quarantine list.

View File

@@ -0,0 +1,254 @@
name: Embedding SDK Component Tests
on:
workflow_call:
inputs:
cached-embedding-sdk-dist-artifact-name:
required: true
type: string
jobs:
sdk-component-tests:
name: "SDK component tests (React ${{ matrix.react-version }})"
runs-on: ubuntu-22.04
timeout-minutes: 45
strategy:
matrix:
react-version: [18, 19]
fail-fast: false
env:
MB_EDITION: ee
DISPLAY: ""
MB_RUN_MODE: "e2e"
METASTORE_DEV_SERVER_URL: ${{ secrets.METASTORE_DEV_SERVER_URL }}
# Any env starting with `CYPRESS_` will be available to all Cypress tests via `Cypress.env()`
# Example: you can get `CYPRESS_FOO` with `Cypress.env("FOO")`
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
CYPRESS_MB_STARTER_CLOUD_TOKEN: ${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }}
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
CYPRESS_MB_PRO_SELF_HOSTED_TOKEN: ${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }}
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title || github.event.head_commit.message || github.event.head.sha }}
TZ: US/Pacific # to make node match the instance tz
CYPRESS_CI: true
steps:
- uses: actions/checkout@v4
- name: Retrieve uberjar artifact for ee
uses: ./.github/actions/fetch-artifact
with:
name: metabase-ee-${{ github.event.pull_request.head.sha || github.sha }}-uberjar
extract-path: "target/uberjar"
output-name: "metabase.jar"
- name: Retrieve Embedding SDK dist artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.cached-embedding-sdk-dist-artifact-name }}
path: ${{ github.workspace }}/resources/embedding-sdk
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare JDK 21
uses: actions/setup-java@v4
with:
java-version: 21
distribution: "temurin"
- name: Prepare Cypress environment
id: cypress-prep
uses: ./.github/actions/prepare-cypress
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start
- name: Make app db snapshot
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: node e2e/runner/run_cypress_ci.js snapshot
# Has to be run after the app db snapshot is made, as it is not compatible with React 19
- name: Change React version to ${{ matrix.react-version }}
run: ./bin/embedding-sdk/change-react-version.bash ${{ matrix.react-version }}
- name: Run component tests for Embedding SDK
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: node e2e/runner/run_cypress_ci.js component
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
output-name: e2e-embedding-sdk-react-${{ matrix.react-version }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: "SDK component tests > Upload Cypress Artifacts upon failure"
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-recording-embedding-sdk-react-${{ matrix.react-version }}
path: |
./cypress
./logs/test.log
if-no-files-found: ignore
- name: "SDK component tests > Publish Summary"
if: failure()
uses: actions/github-script@v7
with:
script: | #js
const {
generateReport,
parseReport,
formatSummary
} = require("./.github/scripts/handle-mochawesome-report.js");
const report = await generateReport();
const results = parseReport(report);
const summary = formatSummary(results);
await core.summary.addRaw(summary).write();
decide-target-ref:
runs-on: ubuntu-latest
outputs:
target-ref: ${{ steps.get-target-ref.outputs.value }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Decide ref to checkout
id: get-target-ref
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "value=${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
else
echo "value=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
fi
# This job ensures that the Embedding SDK package from the current commit (N) works with the
# Embedding SDK Bundle (Metabase JAR) from the previous commit (N-1).
# N - 1 is a target of a pull request, or a literal previous commit when this job is run on push in a branch.
sdk-component-tests-backward-compatibility:
name: "SDK component tests (Backward compatibility)"
needs: [decide-target-ref]
runs-on: ubuntu-22.04
timeout-minutes: 45
env:
MB_EDITION: ee
DISPLAY: ""
MB_RUN_MODE: "e2e"
METASTORE_DEV_SERVER_URL: ${{ secrets.METASTORE_DEV_SERVER_URL }}
# Any env starting with `CYPRESS_` will be available to all Cypress tests via `Cypress.env()`
# Example: you can get `CYPRESS_FOO` with `Cypress.env("FOO")`
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
CYPRESS_MB_STARTER_CLOUD_TOKEN: ${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }}
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
CYPRESS_MB_PRO_SELF_HOSTED_TOKEN: ${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }}
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title || github.event.head_commit.message || github.event.head.sha }}
TZ: US/Pacific # to make node match the instance tz
CYPRESS_CI: true
steps:
- name: Check out the code from the previous commit
uses: actions/checkout@v4
with:
ref: ${{ needs.decide-target-ref.outputs.target-ref }}
- name: Get the actual commit SHA from the checked out branch
id: get-commit-sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Attempt to retrieve the ee uberjar artifact from the previous commit
id: download-uberjar
uses: ./.github/actions/fetch-artifact
with:
name: metabase-ee-${{ steps.get-commit-sha.outputs.sha }}-uberjar
extract-path: "target/uberjar"
output-name: "metabase.jar"
continue-on-error: true # do not break the whole job if this step doesn't succeed
# Build the uberjar from the previous commit only if we couldn't fetch it.
# `step.foo.OUTCOME` is important because it looks at the step status *before* continue-on-error is applied.
# Do not confuse it with the `step.foo.conclusion`.
- name: Build MB uberjar from the commit N-1
if: steps.download-uberjar.outcome != 'success'
run: ./bin/build.sh
- name: Retrieve Embedding SDK dist artifact built from the current commit N
uses: actions/download-artifact@v4
with:
name: ${{ inputs.cached-embedding-sdk-dist-artifact-name }}
path: ${{ github.workspace }}/resources/embedding-sdk
- name: Prepare Cypress environment
id: cypress-prep
uses: ./.github/actions/prepare-cypress
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start
- name: Make app db snapshot
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: node e2e/runner/run_cypress_ci.js snapshot
- name: Run component tests for Embedding SDK
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: |
node e2e/runner/run_cypress_ci.js component \
--env grepTags="-@skip-backward-compatibility"
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
output-name: e2e-embedding-sdk
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-recording-embedding-sdk-cross-version
path: |
./cypress
./logs/test.log
if-no-files-found: ignore
- name: Publish Summary
if: failure()
uses: actions/github-script@v7
with:
script: | #js
const {
generateReport,
parseReport,
formatSummary
} = require("./.github/scripts/handle-mochawesome-report.js");
const report = await generateReport();
const results = parseReport(report);
const summary = formatSummary(results);
await core.summary.addRaw(summary).write();

View File

@@ -1,78 +1,18 @@
name: "[NOT REQUIRED] SDK Compatibility tests"
on:
push:
branches:
- "master"
- "release-**"
pull_request:
types: [opened, synchronize, reopened]
branches:
- "master"
- "release-**"
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
workflow_call:
inputs:
cached-embedding-sdk-dist-artifact-name:
required: true
type: string
jobs:
files-changed:
name: Check which files changed
runs-on: ubuntu-22.04
timeout-minutes: 3
outputs:
e2e_embedding_sdk_compatibility: ${{ steps.changes.outputs.e2e_embedding_sdk_compatibility }}
steps:
- uses: actions/checkout@v4
- name: Test which files changed
uses: dorny/paths-filter@v3.0.0
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
# if this is a test on a release branch, we need to check the build requirements
get-build-requirements:
if: |
!cancelled() &&
contains(github.base_ref || github.ref, 'release-x')
runs-on: ubuntu-22.04
timeout-minutes: 10
outputs:
java_version: ${{ fromJson(steps.dependencies.outputs.result).java_version }}
node_version: ${{ fromJson(steps.dependencies.outputs.result).node_version }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: release
- name: Prepare build scripts
run: cd ${{ github.workspace }}/release && yarn && yarn build
- name: Get build dependencies
uses: actions/github-script@v7
id: dependencies
with:
script: | # js
const {
getBuildRequirements,
getVersionFromReleaseBranch,
} = require('${{ github.workspace }}/release/dist/index.cjs');
const targetBranchName = '${{ github.base_ref || github.ref }}';
const version = getVersionFromReleaseBranch(targetBranchName);
const requirements = getBuildRequirements(version);
return {
java_version: requirements.java,
node_version: requirements.node,
};
get-sample-apps-data:
if: |
!cancelled()
runs-on: ubuntu-22.04
timeout-minutes: 10
env:
# we must support last two stable releases
# we must support the last two stable releases
SUPPORTED_RELEASES_COUNT: 2
TEST_SUITES: |
[
@@ -145,51 +85,6 @@ jobs:
matrix: []
};
build:
needs: [files-changed, get-build-requirements, get-sample-apps-data]
if: |
!cancelled() &&
needs.files-changed.outputs.e2e_embedding_sdk_compatibility == 'true' &&
fromJSON(needs.get-sample-apps-data.outputs.matrix)[0] != null
runs-on: ubuntu-22.04
timeout-minutes: 25
env:
MB_EDITION: ee
INTERACTIVE: false
# make sure that builds on release branches get licenses, because we use them for releases
SKIP_LICENSES: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
with:
node-version: "${{ needs.get-build-requirements.outputs.node_version }}"
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
with:
java-version: "${{ needs.get-build-requirements.outputs.java_version || 21 }}"
- name: Build uberjar with ./bin/build.sh
run: ./bin/build.sh
- name: Prepare uberjar artifact
uses: ./.github/actions/prepare-uberjar-artifact
with:
name: metabase-ee-${{ github.event.pull_request.head.sha || github.sha }}-uberjar
- name: Build Embedding SDK package
run: |
# Remove the matchers to not report false positives on every PR
# unrelated to embedding (EMB-765)
echo "::remove-matcher owner=tsc::"
yarn build-embedding-sdk-package
- name: Prepare Embedding SDK artifact
uses: actions/upload-artifact@v4
with:
name: embedding-sdk-${{ github.event.pull_request.head.sha || github.sha }}
path: resources/embedding-sdk
# In order to check compatibility between Sample Apps and Embedding SDK, the job for each sample app:
# - pulls a Sample App
# - starts it
@@ -197,13 +92,13 @@ jobs:
# The job is not required, though if it's failing better to be sure that it's not caused by a breaking change.
# For more details see `Sample Apps compatibility with Embedding SDK tests` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
e2e-sample-apps-tests:
needs: [get-build-requirements, get-sample-apps-data, build]
needs: [get-sample-apps-data]
strategy:
matrix:
test_suite: ${{ fromJSON(needs.get-sample-apps-data.outputs.matrix) }}
environment: ["production", "development"]
if: |
!cancelled() && needs.build.result == 'success'
!cancelled() && needs.get-sample-apps-data.result == 'success'
runs-on: ubuntu-22.04
timeout-minutes: 45
name: e2e-sample-app-${{ matrix.test_suite }}-tests-${{ matrix.environment }}-bundle
@@ -240,9 +135,11 @@ jobs:
echo "sample_app_name=${test_suite%-e2e}" >> "$GITHUB_OUTPUT"
- name: Retrieve uberjar artifact for ee
uses: actions/download-artifact@v4
uses: ./.github/actions/fetch-artifact
with:
name: metabase-ee-${{ github.event.pull_request.head.sha || github.sha }}-uberjar
extract-path: "target/uberjar"
output-name: "metabase.jar"
- name: Retrieve Embedding SDK dist artifact
uses: actions/download-artifact@v4
@@ -298,7 +195,9 @@ jobs:
id: run-e2e-tests
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
continue-on-error: true
run: node e2e/runner/run_cypress_ci.js ${{ matrix.test_suite }} --browser ${{ steps.cypress-prep.outputs.chrome-path }}
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: node e2e/runner/run_cypress_ci.js ${{ matrix.test_suite }}
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v4
@@ -322,7 +221,6 @@ jobs:
# The job is not required, though if it's failing better to be sure that it's not caused by a breaking change.
# For more details see `Embedding SDK integration tests with Host Apps` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
e2e-host-apps-tests:
needs: [get-build-requirements, build]
strategy:
matrix:
test_suite:
@@ -333,8 +231,6 @@ jobs:
"angular-20-host-app-e2e",
]
environment: ["production", "development"]
if: |
!cancelled() && needs.build.result == 'success'
runs-on: ubuntu-22.04
timeout-minutes: 45
name: e2e-host-app-${{ matrix.test_suite }}-tests-${{ matrix.environment }}-bundle
@@ -392,7 +288,9 @@ jobs:
- name: Run e2e tests for Host App
id: run-e2e-tests
continue-on-error: true
run: node e2e/runner/run_cypress_ci.js ${{ matrix.test_suite }} --browser ${{ steps.cypress-prep.outputs.chrome-path }}
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: node e2e/runner/run_cypress_ci.js ${{ matrix.test_suite }}
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v4

View File

@@ -14,6 +14,7 @@ jobs:
timeout-minutes: 3
outputs:
e2e_embedding_sdk: ${{ steps.changes.outputs.e2e_embedding_sdk }}
e2e_embedding_sdk_compatibility: ${{ steps.changes.outputs.e2e_embedding_sdk_compatibility }}
frontend_embedding_sdk_sources: ${{ steps.changes.outputs.frontend_embedding_sdk_sources }}
embedding_documentation: ${{ steps.changes.outputs.embedding_documentation }}
steps:
@@ -25,7 +26,7 @@ jobs:
token: ${{ github.token }}
filters: .github/file-paths.yaml
build:
build-sdk-package:
needs: [files-changed]
if: |
!cancelled() &&
@@ -53,10 +54,10 @@ jobs:
path: resources/embedding-sdk
embedding-sdk-cli-snippets-type-check:
needs: [files-changed, build]
needs: [files-changed, build-sdk-package]
if: |
needs.files-changed.outputs.frontend_embedding_sdk_sources == 'true' &&
needs.build.result == 'success'
needs.build-sdk-package.result == 'success'
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
@@ -72,10 +73,10 @@ jobs:
run: yarn run embedding-sdk:cli-snippets:type-check
embedding-sdk-docs-snippets-type-check:
needs: [files-changed, build]
needs: [files-changed, build-sdk-package]
if: |
needs.files-changed.outputs.embedding_documentation == 'true' &&
needs.build.result == 'success'
needs.build-sdk-package.result == 'success'
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
@@ -91,10 +92,10 @@ jobs:
run: yarn run embedding-sdk:docs-snippets:type-check
embedding-sdk-api-documentation-generation-validation:
needs: [files-changed, build]
needs: [files-changed, build-sdk-package]
if: |
needs.files-changed.outputs.frontend_embedding_sdk_sources == 'true' &&
needs.build.result == 'success'
needs.build-sdk-package.result == 'success'
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
@@ -140,18 +141,20 @@ jobs:
run: exit 1
# See `Component e2e tests` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
sdk-e2e-tests:
name: "SDK e2e tests"
uses: ./.github/workflows/e2e-component-tests-embedding-sdk.yml
needs: [build]
embedding-sdk-component-tests:
name: "SDK component tests"
needs: [build-sdk-package, files-changed]
if: needs.files-changed.outputs.e2e_embedding_sdk == 'true'
uses: ./.github/workflows/embedding-sdk-component-tests.yml
secrets: inherit
with:
cached-embedding-sdk-dist-artifact-name: embedding-sdk-${{ github.event.pull_request.head.sha || github.sha }}
e2e-component-tests-embedding-sdk-backward-compatibility:
name: "SDK e2e backward compatibility tests"
uses: ./.github/workflows/e2e-component-tests-embedding-sdk-backward-compatibility.yml
needs: [build]
not-required-embedding-sdk-apps-compatibility-tests:
name: "SDK e2e tests for compatibility with (host/sample) apps"
needs: [build-sdk-package, files-changed]
if: needs.files-changed.outputs.e2e_embedding_sdk_compatibility == 'true'
uses: ./.github/workflows/embedding-sdk-e2e-tests.yml
secrets: inherit
with:
cached-embedding-sdk-dist-artifact-name: embedding-sdk-${{ github.event.pull_request.head.sha || github.sha }}
@@ -161,8 +164,7 @@ jobs:
- embedding-sdk-cli-snippets-type-check
- embedding-sdk-docs-snippets-type-check
- embedding-sdk-api-documentation-generation-validation
- sdk-e2e-tests
- e2e-component-tests-embedding-sdk-backward-compatibility
- embedding-sdk-component-tests
if: always() && !cancelled()
runs-on: ubuntu-latest
timeout-minutes: 5

View File

@@ -42,7 +42,7 @@ jobs:
- name: Restore ESLint cache
uses: actions/cache/restore@v4
with:
path: .eslintcache
path: ${{ github.workspace}}/.eslintcache
key: ${{ steps.get-cache-keys.outputs.eslint-cache-key }}
- name: Compile CLJS

227
.github/workflows/minimal-tests.yml vendored Normal file
View File

@@ -0,0 +1,227 @@
name: Minimal Tests
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
# Only run if the PR has the minimal-tests label
check-label:
runs-on: ubuntu-latest
timeout-minutes: 1
outputs:
should-run: ${{ steps.check.outputs.result }}
steps:
- name: Check for minimal-tests label
id: check
run: |
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'minimal-tests') }}" == "true" ]]; then
echo "result=true" >> $GITHUB_OUTPUT
else
echo "result=false" >> $GITHUB_OUTPUT
fi
# Postgres app-db tests - single version, single job
postgres-app-db-test:
needs: check-label
if: needs.check-label.outputs.should-run == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 60
env:
CI: 'true'
DRIVERS: postgres
MB_DB_TYPE: postgres
MB_DB_PORT: 5432
MB_DB_HOST: localhost
MB_DB_DBNAME: mb_test
MB_DB_USER: mb_test
MB_POSTGRESQL_TEST_USER: mb_test
services:
postgres:
image: postgres:latest
ports:
- "5432:5432"
env:
POSTGRES_USER: mb_test
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
name: Postgres Latest EE App DB Tests (Part 1)
steps:
- uses: actions/checkout@v4
- name: Test Postgres App DB
uses: ./.github/actions/test-driver
with:
build-static-viz: true
junit-name: 'minimal-be-tests-postgres-ee'
test-args: >-
:only '["test" "enterprise/backend/test"]'
:exclude-tags '[:mb/driver-tests :mb/transforms-python-test]'
:partition/total 2
:partition/index 0
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
# Postgres driver tests
postgres-driver-test:
needs: check-label
if: needs.check-label.outputs.should-run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
CI: 'true'
DRIVERS: postgres
MB_DB_TYPE: postgres
MB_DB_PORT: 5432
MB_DB_HOST: localhost
MB_DB_DBNAME: mb_test
MB_DB_USER: mb_test
MB_POSTGRESQL_TEST_USER: mb_test
services:
postgres:
image: postgres:latest
ports:
- "5432:5432"
env:
POSTGRES_USER: mb_test
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
name: Postgres Latest Driver Tests
steps:
- uses: actions/checkout@v4
- name: Test Postgres Driver
uses: ./.github/actions/test-driver
with:
junit-name: 'minimal-be-tests-postgres-driver-ee'
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
# Frontend unit tests
frontend-unit-tests:
needs: check-label
if: needs.check-label.outputs.should-run == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
JEST_JUNIT_OUTPUT_DIR: ./target/junit
JEST_JUNIT_OUTPUT_NAME: test-report-frontend-unit.xml
name: Frontend Unit Tests
steps:
- uses: actions/checkout@v4
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Run frontend unit tests
run: yarn run test-unit --silent
continue-on-error: true
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
output-name: frontend-unit
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
# Summary job
minimal-tests-result:
needs:
- postgres-app-db-test
- postgres-driver-test
- frontend-unit-tests
runs-on: ubuntu-latest
timeout-minutes: 5
name: minimal-tests-result
if: always() && !cancelled()
steps:
- name: Check job status
uses: actions/github-script@v7
env:
needs: ${{ toJson(needs) }}
with:
script: | # js
const needs = JSON.parse(process.env.needs);
const jobs = Object.entries(needs).map(
([jobName, jobValues]) => ({
name: jobName,
result: jobValues.result
}));
// Check if all jobs either succeeded or were skipped
// Skipped jobs are acceptable (they were intentionally skipped when the label is absent)
const allPassedOrSkipped = jobs.every(job =>
job.result === 'success' || job.result === 'skipped'
);
// Check if any jobs actually failed (not skipped, not cancelled)
const anyFailed = jobs.some(job =>
job.result === 'failure'
);
if (allPassedOrSkipped && !anyFailed) {
console.log("");
console.log(" _------. ");
console.log(" / , \_ ");
console.log(" / / /{}\ |o\_ ");
console.log(" / \ `--' /-' \ ");
console.log(" | \ \ | ");
console.log(" | |`-, | ");
console.log(" / /__/)/ ");
console.log(" | | ");
console.log("");
console.log("All minimal tests have passed. Fast feedback achieved!");
process.exit(0);
}
// otherwise, something failed
console.log("");
console.log(" .::::::::::. .::::::::::. ");
console.log(" .::::''''''::::. .::::''''''::::. ");
console.log(" .:::' `::::.... ....::::' `:::. ");
console.log(" .::' `:::::::| |:::::::' `::. ");
console.log(" .::| |::::::|_ ___ __|::::::| |::. ");
console.log(" `--' |::::::|_()__()_|::::::| `--' ");
console.log(" ::: |::-o::| |::o-::| ::: ");
console.log(" `::. .|::::::| |::::::|. .::' ");
console.log(" `:::. .::\-----' `-----/::. .:::' ");
console.log(" `::::......::::' `::::......::::' ");
console.log(" `::::::::::' `::::::::::' ");
console.log("");
console.log("Minimal tests have failed.");
jobs.forEach((job) => {
if (job.result !== 'success' && job.result !== 'skipped') {
console.log(`${job.name} - ${job.result}`);
}
});
process.exit(1);

View File

@@ -194,19 +194,21 @@ jobs:
- name: Run a few important OSS Cypress tests as sanity check
if: ${{ matrix.edition == 'oss' }}
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: |
node e2e/runner/run_cypress_ci.js e2e \
--env grepTags="@smoke+-@EE",grepOmitFiltered=true \
--spec './e2e/test/scenarios/**/*.cy.spec.(js|ts)' \
--browser ${{ steps.cypress-prep.outputs.chrome-path }}
--env grepTags="@smoke+-@EE" \
--spec './e2e/test/scenarios/**/*.cy.spec.(js|ts)'
- name: Run a few important EE Cypress tests as sanity check
if: ${{ matrix.edition == 'ee' }}
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: |
node e2e/runner/run_cypress_ci.js e2e \
--env grepTags="@smoke+-@OSS",grepOmitFiltered=true \
--spec './e2e/test/scenarios/**/*.cy.spec.(js|ts)' \
--browser ${{ steps.cypress-prep.outputs.chrome-path }}
--env grepTags="@smoke+-@OSS" \
--spec './e2e/test/scenarios/**/*.cy.spec.(js|ts)'
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v4

View File

@@ -12,7 +12,7 @@ on:
jobs:
files-changed:
if: ${{ !contains(github.event.pull_request.labels.*.name,'ci skip') }}
if: ${{ !contains(github.event.pull_request.labels.*.name,'ci skip') && !contains(github.event.pull_request.labels.*.name,'minimal-tests') }}
name: Check which files changed
runs-on: ubuntu-22.04
timeout-minutes: 5
@@ -36,49 +36,19 @@ jobs:
list-files: csv
filters: .github/file-paths.yaml
static-viz-files-changed:
name: Check whether static-viz files changed
if: ${{ !contains(github.event.pull_request.labels.*.name,'ci skip') }}
runs-on: ubuntu-22.04
timeout-minutes: 10
outputs:
static_viz: ${{ steps.static_viz.outputs.static_viz }}
steps:
- uses: actions/checkout@v4
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Build static-viz frontend
run: yarn build-static-viz
env:
MB_EDITION: ee
- name: Upload Static Viz Bundle Artifact
uses: actions/upload-artifact@v4
with:
name: static-viz-bundle-${{ github.sha }}
path: resources/frontend_client/app/dist
- name: Check for static viz changes
uses: dorny/paths-filter@v3.0.0
id: static_viz
with:
token: ${{ github.token }}
filters: .github/static-viz-sources.yaml
uberjar:
needs: [files-changed, static-viz-files-changed]
needs: [files-changed]
if: ${{ !cancelled() && !(needs.files-changed.outputs.documentation == 'true' && needs.files-changed.outputs.backend_all == 'false' && needs.files-changed.outputs.frontend_all == 'false' && needs.files-changed.outputs.e2e_all == 'false') }}
uses: ./.github/workflows/uberjar.yml
secrets: inherit
backend-tests:
if: ${{ !cancelled() && needs.files-changed.result == 'success' }}
needs: [files-changed, static-viz-files-changed]
needs: [files-changed]
uses: ./.github/workflows/backend.yml
secrets: inherit
with:
skip: ${{ needs.files-changed.outputs.backend_all != 'true' && needs.static-viz-files-changed.outputs.static_viz != 'true' }}
skip: ${{ needs.files-changed.outputs.backend_all != 'true' }}
semantic-search-tests:
needs: files-changed
@@ -123,7 +93,7 @@ jobs:
specs: ${{ needs.files-changed.outputs.e2e_all_files == needs.files-changed.outputs.e2e_specs_files && needs.files-changed.outputs.e2e_specs_files || '' }}
sdk-tests:
needs: files-changed
needs: [uberjar]
uses: ./.github/workflows/embedding-sdk.yml
secrets: inherit

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,5 +1,8 @@
accept = ["200..=204", "429"]
exclude_path = ["docs/embedding/sdk"]
exclude = ["\\.html(#.*)?$"]
exclude_path = ["docs/util"]
no_progress = true
include_fragments = true
verbose = "error"
verbose = "error"
include_verbatim = false
cache = false

View File

@@ -2,4 +2,4 @@ frontend/src/cljs
docs/configuring-metabase/environment-variables.md
docs/api/*
docs/api-documentation.md
enterprise/frontend/src/embedding-sdk-shared/.storybook/msw-public/mockServiceWorker.js
frontend/src/embedding-sdk-shared/.storybook/msw-public/mockServiceWorker.js

724
CLAUDE.md
View File

@@ -1,722 +1,20 @@
# Metabase Development Guide
## Autonomous Development Workflow
# Skills
- Do not attempt to read or edit files outside the project folder
- Add failing tests first, then fix them
- Work autonomously in small, testable increments
- Run targeted tests, and lint continuously during development
- Prioritize understanding existing patterns before implementing
- Don't commit changes, leave it for the user to review and make commits
For detailed guidance on writing and reviewing code and documentation, see the skills in [.claude/skills/](.claude/skills/):
## Quick Commands
## Clojure
### JavaScript/TypeScript
- **[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
- **Lint:** `yarn lint-eslint-pure`
- **Test:** `yarn test-unit-keep-cljs path/to/file.unit.spec.js` or `yarn test-unit-keep-cljs -t "pattern"`
- **Format:** `yarn prettier`
- **Type Check:** `yarn type-check-pure`
## TypeScript
### Clojure
- **[typescript-write](.claude/skills/typescript-write/SKILL.md)** - TypeScript/JavaScript development and best practices
- **[typescript-review](.claude/skills/typescript-review/SKILL.md)** - TypeScript/JavaScript code review guidelines
- **Lint PR:** `./bin/mage kondo-updated master` (or whatever target branch)
- Call the command one time at the beginning, record the results, then work through the problems one at a time.
- If the solution is obvious, then please apply the fix. Otherwise skip it.
- If you fix all the issues (and verify by rerunning the kondo-updated command):
- commit the change with a succinct and descriptive commit message
- **Lint File:** `./bin/mage kondo <file or files>` (or whatever target branch)
- Use the linter as a way to know that you are adhering to conventions in place in the codebase
- **Lint Changes:** `./bin/mage kondo-updated HEAD`
- **Format:** `./bin/mage cljfmt-files [path]`
- **Run a test:** `./bin-mage run-tests namespace/test-name`
- **Run all tests in a namespace:** `./bin-mage run-tests namespace`
- **Check Code Readability** `./bin/mage -check-readable` with optional line-number
- Run this after every change to Clojure code, only accept readable code
- **Evaluating Clojure Code** `./bin/mage -repl '<code>'`
- See `Sending code to the REPL` for more details
## Documentation
### ClojureScript
- **Test:** `yarn test-cljs`
## Clojure REPL-driven development
- Start with small, fundamental functions:
- Identify the core features or functionalities required for your task.
- Break each feature down into the smallest, most basic functions that can be developed and tested independently.
- Write and test in the REPL:
- Write the code for each small function directly in the REPL (Read-Eval-Print Loop).
- Test it thoroughly with a variety of inputs, including typical use cases and relevant edge cases, to ensure it
behaves as expected.
- Integrate into source code:
- Once a function works correctly in the REPL, move it from the REPL environment into your source code files (e.g.,
within appropriate namespaces).
- Gradually increase complexity:
- Build upon tested, basic functions to create more complex functions or components.
- Compose smaller functions together, testing each new composition in the REPL to verify correctness step by step.
- Ensure dependency testing:
- Make sure every function is fully tested in the REPL before it is depended upon by other functions.
- This ensures that each layer of your application is reliable before you build on it.
- Leverage the REPL fully:
- Use the REPL as your primary tool to experiment with different approaches, iterate quickly, and get immediate
feedback on your code.
- Follow functional programming principles:
- Keep functions small, focused, and composable.
- Leverage Clojure's functional programming features—like immutability, higher-order functions, and the standard
library—to write concise, effective code.
### How to evaluate code
#### Keeping Code Readable
The `./bin/mage -check-readable <file> <optional: line-number>` command checks if your Clojure code can be properly parsed.
This ensures your changes maintain valid syntax and structure.
- Edit Clojure files one step at a time.
- After EVERY change to a Clojure form, call `mage -check-readable src/metabase/thefile.clj <line-number>` with the line number.
- If it's readable then call `mage -check-readable dev/src/dev.clj` without the line number to check the entire file.
- If the change results in unreadable code, try again until it is readable.
- To overcome errors about parens, pay close attention to them. Count opening/closing parens you add/remove.
#### Bottom-up dev loop
1. Write code into a file.
2. Evaluate the file's namespace and make sure it loads correctly with:
```
mage -repl --namespace metabase.app-db.connection
```
3. Call functions in the namespace with test inputs, and observe that the outputs are correct 3.1
Feel free to copy these REPL session trials into actual test cases using `deftest` and `is`.
4. Once you know these functions are good, return to 1, and compose them into the task that you need to build.
#### Sending code to the REPL
- Send code to the metabase process REPL using: `./bin/mage -repl '(+ 1 1)'` where `(+ 1 1)` is your Clojure code.
- See `./bin/mage -repl -h` for more details.
- If the Metabase backend is not running, you'll see an error message with instructions on how to start it.
##### Working with files and namespaces
1. **Load a file and call functions with fully qualified names**:
To call `your.namespace/your-function` on `arg1` and `arg2`:
```
./bin/mage -repl --namespace your.namespace '(your-function arg1 arg2)'
```
DO NOT use "require", "load-file" etc in the code string argument.
##### Understanding the response
The `./bin/mage -repl` command returns three separate, independent outputs:
- `value`: The return value of the last expression (best for data structures)
- `stdout`: Any printed output from `println` etc. (best for messages)
- `stderr`: Any error messages (best for warnings and errors)
Example call:
```bash
./bin/mage -repl '(println "Hello, world!") '\''({0 1, 1 3, 2 0, 3 2} {0 2, 1 0, 2 3, 3 1})'
```
Example response:
```
ns: user
session: 32a35206-871c-4553-9bc9-f49491173d1c
value: ({0 1, 1 3, 2 0, 3 2} {0 2, 1 0, 2 3, 3 1})
stdout: Hello, world!
stderr:
```
For effective REPL usage:
- Return data structures as function return values
- Use `println` for human-readable messages
- Print errors to stderr
## Tips
- End all files with a newline.
- When editing tabular code, where the columns line up, try to keep them aligned.
- Spaces on a line with nothing after it is not allowed
## Critical REPL Usage Rules
- Be careful with parentheses counts when editing Clojure code
- After EVERY change to Clojure code, verify readability with `-check-readable`
# Code Review Guidelines
Review pull requests with a focus on:
- Compliance with project coding standards and conventions
- Code quality and best practices
- Clear and correct docstrings
- Clear and correct comments explaining what the code is doing
## More Instructions
- Carefully read the Community Clojure Style Guide from `CLOJURE_STYLE_GUIDE.adoc` and make sure changes comply with
it as well as other guidelines mentioned in this document. Flag _all_ violations of the Community Clojure Style
Guide.
- Do not post comments congratulating someone for trivial changes, or for following the style guidelines, or
confirming that things "look good", or telling them that they did something correctly; only post comments about
style violations or potential issues. Example bad code review comments to avoid:
> This TODO comment is properly formatted with author and date - nice work!
> Good addition of limit 1 to the query - this makes the test more efficient without changing its behavior.
> The kondo ignore comment is appropriately placed here
> The kondo ignore is properly used here. The comment on lines 42-45 explains why hardcoded driver names are needed,
> which is good context.
> Test name properly ends with -test as required by the style guide.
- Do not post comments about missing parentheses.
# Code Conventions and Style Guide
## Clojure and ClojureScript
### General Conventions
- Prefer longer, more verbose names for functions and variables; avoid abbreviations unless they are well-known and
conventional in the Clojure world. `acc`, `i`, `pred`, `coll`, `n`, `s`, `k`, and `f` are examples of well-known
conventions; any Clojure developer has seen them before and can tell you what they mean. Avoid unconventional
abbreviations like `tbl` and unclear variable names like `zs'`. A good function or variable name should make its
purpose immediately clear. Error on the side of longer variable names if needed.
```clj
;; too cryptic
(defn mayb+1 [n]
(when n
(inc n)))
;; just right
(defn maybe-inc [n]
(when n
(inc n)))
```
**Why?** Code is read many more times than it is written, and clearer variable names make using and tweaking your
code easier for others.
Conventions used widely in our codebase (like `mp` for a Metadata Provider) are acceptable; one-offs like `zs'` are
not.
- Avoid misleading variable and function names. The names of a variable or function should clearly and unambiguously
describe its purpose and match what it does.
```clj
;; bad
(defn nil-or-maplist? [v] ; coll would be a better variable name because it's more specific
(or (nil? v)
(and (sequential? v) ; v can actually be an array, vector, list, or lazy seq
(every? map? v))))
;; good
(defn nil-or-sequence-of-maps? [coll]
(or (nil? coll)
(and (sequential? coll)
(every? map? coll))))
```
**Why?** Poorly-named functions are prone to being used in cases where they're inappropriate or avoided in cases
when they would be suitable.
- Pure function names should be nouns describing the value they return.
For example, a function to compute a user's age based on their birthdate should be called `age`, not `calculate-age`
or `get-age`.
**Why?** A pure function is one which can be replaced with its value without affecting the result, so the name
should reflect that.
- Don't repeat the usual alias of the namespace a function belongs to in the name of a function itself.
```clj
(ns metabase.config)
;; bad
(defn config-is-dev? [] ...)
;; good
(defn is-dev? [] ...)
```
**Why?** It's obvious that `is-dev?` in the example above is referring to `dev`, because it's in the `config`
namespace. It's also needlessly noisy when using the function in another namespace:
```clj
;; bad
(when (config/config-is-dev?)
...)
;; good
(when (config/is-dev?)
...)
```
In some cases, following this rule will require you to use a `(:refer-clojure :exclude [...])` form in your namespace
declaration. This is acceptable, and should be taken as a sign that you're following this rule correctly.
- If you shadow a `clojure.core` var, make sure you use `(:refer-clojure :exclude ...)` in the `ns` form.
- Make everything `^:private` unless it is used elsewhere.
Don't make things public just for the sake of tests. Use the var form (e.g. `#'redshift/execute!`) instead in your
tests.
**Why?** It's much easier to read and refactor code when you know its scope is limited to the current namespace.
- Tag variables with `:arglists` metadata if they are functions but wouldn't otherwise have it, such as when using
`def` to define partial functions or function compositions e.g.
```clj
(def ^{:arglists '([n])} plus-one (partial + 1))
```
**Why?** Good editors use this metadata show the expected arguments to a function as you're writing code.
- Try to organize namespaces in such a way that you don't need to use `declare`. This usually means putting the public
portion of a namespace near the end of a file.
**Why?** Avoiding declare when unnecessary forces us to read and write code in a consistent manner, that is, from
top to bottom. When code is written in this consistent order we can safely assume referenced functions sit somewhere
above their reference in the namespace; this makes the code easier to navigate.
- Don't mark things `^:const` unless you have a really good reason for doing so. Add a comment explaining why you
marked it `^:const`.
- Every public var in `src` or `enterprise/backend/src` must have a _useful_ docstring. A useful docstring
should clearly explain the purpose of the function, its inputs and outputs, and anything else that is otherwise not
immediately clear. If there are other functions that have similar purposes, explain how the use-cases for this
function differ.
Tests and other vars in the `test` or `enterprise/backend/test` do not _require_ docstrings, but general helper
functions used across many namespaces it should have docstrings.
Schemas defined with `mr/def` e.g. `(mr/def ::my-schema)` do not require docstrings.
- Format docstrings according to Markdown conventions. (https://guide.clojure.style/#markdown-docstrings)
- Mentions of other vars in docstrings should use `[[some-other-var]]` (for vars in the same namespace) or
`[[metabase.namespace/some-other-var]]` (for vars in a different namespace) instead of backticks; references in
docstrings should be valid (i.e., point to something that exists).
(https://guide.clojure.style/#document-references)
- Judiciously use comments to explain sections of code that would not immediately be clear to someone else. Avoid
comments that do little more than repeat what the code already says.
- Make sure to update comments and docstrings when you change the code they describe.
- `TODO` comments should include the author and date, for example
```clj
;; TODO (Cam 10/7/25) -- this is a properly formatted TODO comment
(...)
```
- Break up larger functions (> 20 lines) in source code (`src` or `enterprise/backend/src`) whenever possible. Small
functions are much easier to test, understand, and tweak. Tests are allowed to be longer, especially when they
contain mock data, but they should probably never be more than 100 lines.
- Try to keep lines 120 characters wide or less; use this as a guideline when formatting docstrings or comments.
- **No Blank Lines Within Definition Forms** Do not place blank lines in the middle of a function or macro definition.
An exception can be made to indicate grouping of pairwise constructs as found in e.g. `let` and `cond`, in case
those don’t fit on the same line. `deftest` is **NOT** an exception to this rule.
(https://guide.clojure.style/#no-blank-lines-within-def-forms) Verify that the line in question is **actually
blank** (i.e., only contains whitespace for the **entire line**) before warning about this. **A line that contains
any non-whitespace character is not a blank line.** You will be shut down if you make this mistake too many times,
so be careful.
- Use `kebab-case` names for variables and defs, including constants.
(https://guide.clojure.style/#naming-functions-and-variables)
```clj
;;; BAD
(def MY_CONSTANT 100)
;;; GOOD
(def my-constant 100)
;;; BAD
(defn myFunction [my_arg] ...)
;;; GOOD
(defn my-function [my-arg] ...)
```
- Map destructuring should use kebab-case local bindings even if the map it was destructured from uses `snake_case`
keys or if it is returned as a value for a `snake_case` key.
```clj
;; Good
(let [{database-id :database_id} some-object]
{:database_id database-id, :table_id 100})
;; Bad
(let [{database_id :database_id} some-object]
{:database_id database_id, :table_id 100})
```
- Prefer namespaced keywords for keywords that are used internally (i.e., not returned by the REST API or persisted by
the app DB):
```clj
;;; good
(defn query-type [x]
(if (some-pred? x)
:query-type/normal
:query-type/crazy))
;;; bad
(defn query-type [x]
(if (some-pred? x)
:normal
:crazy))
```
These are easy to search across the entire application and makes their origin clearer.
- Functions that have side-effects such as writing to the application database or mutating the global state of the
application should have names that end in exclamation points. Exclamation points should be considered "sticky", so
if a function uses another function with a name ending in an exclamation point, it too should have a name that ends
in an exclamation point. (https://guide.clojure.style/#naming-unsafe-functions)
An exception is functions that write log messages or other output to the console; these don't need exclamation
points.
### Tests
- Large tests should be broken out into separate `deftest` forms when they consist of several logically separate test
cases.
```clj
(deftest ^:parallel my-test
(testing "Some logically discrete test case"
(is ...)))
(deftest ^:parallel my-other-test
(testing "Another logically discrete test case"
(is ...)))
(deftest ^:parallel my-third-different-test
(testing "A third logically discrete test case"
(is ...)))
(deftest ^:parallel my-amazing-test
(testing "A fourth logically discrete test case"
(is ...)))
```
is preferable to
```clj
(deftest ^:parallel my-test
(testing "Some logically discrete test case"
(is ...))
(testing "Another logically discrete test case"
(is ...))
(testing "A third logically discrete test case"
(is ...))
(testing "A fourth logically discrete test case"
(is ...)))
```
- Mark pure function tests `^:parallel`.
- Test utility functions that are not thread-safe/safe in `^:parallel` tests should have names that end in an
exclamation mark.
- Test names in `deftest` forms should end in `-test` or `-test-<number>` e.g. `whatever-test` or `whatever-test-2` or
even `whatever-test-2b`. (https://guide.clojure.style/#test-naming)
### Modules
- The backend codebase is broken out into separate modules.
- The module configuration file lives in `.clj-kondo/config/modules/config.edn`. There is one entry for each module.
The entry has several keys, but the important ones are `:api` -- the list of namespaces this module provides for use
outside of the module -- and `:uses` -- the list of modules this module directly relies on.
- An OSS follows the pattern `metabase.<module>.*` (for the Clojure namespace) and `src/metabase/<module>/**`(for the
source files) with tests inside the corresponding `test/metabase/<module>/` directory, e.g. the `dashboards` module
is everything inside `src/metabase/dashboards/` and `test/metabase/dashboards/`; it might have a
`metabase.dashboards-rest.api` namespace that corresponds to the file `src/metabase/dashboards/api.clj`.
- An EE module follows the pattern `metabase-enterprise.<module>.*` (for the Clojure namespaces) and
`enterprise/backend/src/metabase_enterprise/<module>/**` (for the source files). The module name uses the
`enterprise/` prefix. For example, `enterprise/billing` is everything in the
`enterprise/backend/src/metabase_enterprise/billing/` directory (for source code) and
`enterprise/backend/test/metabase_enterprise/billing/` (for tests); it might have a namespace called
`metabase-enterprise.billing.api` which corresponds to the file
`enterprise/backend/src/metabase_enterprise/billing/api.clj`.
- REST API endpoints (defined by the `defendpoint` macro) should live in a `<module>.api` or `<module>.api.*`
namespace, e.g. `metabase.dashboards.api` or `metabase.dashboards.api.x`.
- When you add a new API endpoint namespace, you need to add a mapping for it in `metabase.api-routes.routes`.
- Put any functions used by other modules (the API meant for the rest of the backend code) in `<module>.core` .
`.core` should import stuff with Potemkin/ `metabase.util.namespace` and not be used inside the module itself. It’s
also nice to put a `:consistent-alias` entry for this namespace in the Kondo config.
- The `<module>.core` namespace should generally be empty other than importing things from other namespaces with
Potemkin (`potemkin/import-vars` and the like).
- Put Toucan models related to a feature in `<module>.models.*` and add mappings in `metabase.models.resolution`.
- Put scheduled Quartz tasks in `<module>.task.*`.
- Put event handlers (things that use the events subsystem in `metabase.events.core`) in `<module>.event.*`.
- Put `defsetting`s (Settings) in `<module>.settings`.
- Quartz tasks, event handlers, and Settings all need to be loaded on launch, so if you have any of the above add
them to a `<module>.init` namespace and require it in `metabase[-enterprise].core.init`.
- Don't try to cheat the module linters by using things like `#_{:clj-kondo/ignore [:metabase/modules]}`. Note that
this does not apply to using `:clj-kondo/ignore` to disable warnings for other linters besides `:metabase/modules`.
- Put Malli schemas in `<module>.schema`.
- Aim to keep the number of namespaces used outside of a module small. A module should only need at most a subset of
`<module>.api`, `<module>.settings`, `<module>.schema`, `<module>.init`, and `<module>.core`.
- Try to minimize the direct dependencies of a module as well as the indirect dependencies. Modules that are used by
lots of other modules (such as `util`) ideally will have no dependencies on other modules. Our goal is to make as
many modules as possible be "leaf nodes".
- Module names should be match the customer-facing name of the feature they concern, usually a plural noun, for
example, `dashboards` rather than `dashboard`.
### Settings
- Don't define configurable options that can only be set with environment variables; use an `:internal` `defsetting`
instead. We have lots of tooling around `defsetting`.
### REST API Endpoints
- All new REST API Endpoints (defined by `defendpoint`) should have a response schema (denoted by `:- <schema>` after
the route string).
```clj
;;; BAD
(api.macros/defendpoint :get "/"
"Get a list of all transform tags."
...)
;;; GOOD
(api.macros/defendpoint :get "/" :- [:sequential ::whatever-this-returns]
"Get a list of all transform tags."
...)
```
- Query parameters should also use kebab-case e.g. `GET /api/dashboards?include-archived=true` is good while `GET
/api/dashboards?include_archived=true` or `GET /api/dashboards?includeArchived=true` is bad.
- HTTP request bodies should use `snake_case`.
- REST API endpoints should have routes that use singular nouns, for example `GET /api/dashboard/:id` to get a
Dashboard rather than `GET /api/dashboards/:id`.
- `GET` endpoints should not have side effects outside of analytics like updating last-viewed-at timestamps or
recording usage metrics. A `GET` endpoint should not be creating new rows in the application database representing
user-facing objects, for example `GET /api/dashboard` should not attempt to populate empty Dashboards with content
by creating new rows in the application database.
- `defendpoint` forms should be small wrappers around Toucan model code. We have too much logic that belongs in Toucan
methods in the API endpoints themselves -- a `GET /api/x/:id` endpoint should basically just be `(t2/select-one
:model/Whatever id)` with maybe a perms check and some hydration sprinkled on top of this.
- All API endpoints should have Malli schemas for any parameters that aren't ignored. `_route-params` doesn't need a
schema, but `{:keys [x]}` should have one.
- Malli schemas for REST API endpoints should be detailed enough that someone can look at them and know how to use the
endpoint.
Example: for an endpoint like this:
```clj
(api.macros/defendpoint :post "/check_transform"
"Check a proposed edit to a transform, and return the card, transform, etc. IDs for things that will break."
[_route-params
_query-params
{:keys [id source target] :as _body} :- ::transform-body]
...)
```
This is a bad schema for the request body:
```clj
;;; Bad, does not specify the shape of :source and :target in any real detail
(mr/def ::transform-body
[:map
[:id {:optional false} ms/PositiveInt]
[:name {:optional true} :string]
[:source {:optional true} [:maybe ms/Map]]
[:target {:optional true} [:maybe ms/Map]]])
```
While this is better:
```clj
;;; Better, because it tells us the expected shape of :source and :target
(mr/def ::transform-body
[:map
[:id {:optional false} ms/PositiveInt]
[:name {:optional true} :string]
[:source {:optional true} [:maybe [:map [:x pos-int?] [:y pos-int?]]]]
[:target {:optional true} [:maybe [:map [:a pos-int?] [:b pos-int?]]]]])
```
- All new REST API endpoints **MUST HAVE TESTS**.
### MBQL
- No raw MBQL introspection or manipulation should be done outside of Lib (the `lib` and `lib-be` modules) or the
Query Processor (the `query-processor` module) modules. MBQL maps include the `:model/Card` `dataset_query`, you can
usually recognize it when you see a map with a `:database` key and either `:type` or `:lib/type`. You should treat
this map as an opaque object outside of the aforementioned modules; pretend you didn't know it has a `:database` or
`:type` key.
- Use Lib and MBQL 5 in all new **source** code instead of legacy MBQL; avoid use of the `legacy-mbql` module, or the
`metabase.query-processor.store` namespace, in new code. Any code that checks whether a query `:type` is `:native`
or `:query` is a gigantic code smell. Exception: we will allow legacy MBQL in tests... for now.
- Use of `qp.store/with-metadata-provider` or `mt/with-metadata-provider` should be discouraged and phased out.
## Models and the Application Database
- Model names should be singular nouns, e.g. `:model/Dashboard` and not `:model/Dashboards`.
- Table names in the application database should be singular nouns, e.g. `transform` instead of `transforms`.
- The application database should use `snake_case` identifiers for table and column names.
## Using Toucan
- Never fetch an entire row from the application database only to immediately discard everything except for the value
of one column, this is super inefficient and also icky. Use `t2/select-one-fn` instead.
```clj
;;; Bad
(:database_id (t2/select-one :model/Card :id 1))
;;; Good
(t2/select-one-fn :database_id :model/Card :id 1)
;;; Even better -- this does SELECT database_id instead of SELECT *
(t2/select-one-fn :database_id [:model/Card :database_id] :id 1)
```
- One of the big ideas behind Toucan is there's never supposed to be a question of "what function to I use to create
an X correctly" or "what function do I use to update a Y correctly" -- the answer is always supposed to be
`t2/select`, `t2/update!`, etc. That's why they're backed by multimethods, so you can put the correct behavior in
the model's method and then it becomes impossible for you to forget to call the correct function. Avoid adding
functions like `select-dashboards` or `update-dashboard!` -- put this functionality in Toucan methods for the model
in question. This includes things like firing off events.
- Avoid the `mi/define-batched-hydration-method` and `mi/define-simple-hydration-method` helpers in new code; define
method implementations with `(methodical/defmethod t2/batched-hydrate ...)` directly instead.
### Drivers
- All new driver multimethods must be mentioned in `docs/developers-guide/driver-changelog.md`.
- All new driver multimethods should use Lib-style kebab-cased metadata and MBQL 5 queries.
- Driver multimethod implementations (`defmethod` forms) should explicitly pass any `driver` argument to any other
driver multimethods it invokes, rather than hardcoding the driver name.
```clj
;;; Good
(defmethod driver/driver-method :postgres
[driver x]
(driver/some-other-method driver x))
;;; Bad
(defmethod driver/driver-method :postgres
[_driver x]
(driver/some-other-method :postgres x))
```
This is because other drivers may inherit from this driver and we need to propagate the driver name passed in to any
method implementations it inherits.
- Drivers should only use methods from the `driver` or `driver-api` modules.
- Be sure to minimize the amount of logic you're doing inside the `read-column-thunk` in JDBC-based drivers as much as
possible. Here's our Athena code for java.sql.Types/TIMESTAMP_WITH_TIMEZONE for example:
```clj
(defmethod sql-jdbc.execute/read-column-thunk [:athena Types/TIMESTAMP_WITH_TIMEZONE]
[_driver ^ResultSet rs _rs-meta ^Long i]
(fn []
;; Using ZonedDateTime if available to conform tests first. OffsetDateTime if former is not available.
(when-some [^Timestamp timestamp (.getObject rs i Timestamp)]
(let [timestamp-instant (.toInstant timestamp)
results-timezone (driver-api/results-timezone-id)]
...))))
```
Here's a tiny change that will avoid calling (driver-api/results-timezone-id) potentially 10 million times (for a
potential million-row result with 10 timestamp columns)
```clj
(defmethod sql-jdbc.execute/read-column-thunk [:athena Types/TIMESTAMP_WITH_TIMEZONE]
[_driver ^ResultSet rs _rs-meta ^Long i]
(let [results-timezone (driver-api/results-timezone-id)]
(fn []
;; Using ZonedDateTime if available to conform tests first. OffsetDateTime if former is not available.
(when-some [^Timestamp timestamp (.getObject rs i Timestamp)]
(let [timestamp-instant (.toInstant timestamp)]
...)))))
```
- Avoid defining new datasets with `defdataset` as much as possible, since loading test data in cloud-based databases
is crazy slow.
### Misc
- Example data should be bird-themed if possible.
- Any comments that are written by or mention `Cam` should be given bonus points.
### Kondo
- Kondo linter warnings are suppressed with a comment proceeding the form to ignore warnings in e.g.
```clj
#_{:clj-kondo/ignore [:metabase/disallow-hardcoded-driver-names-in-tests]}
(do-x ...)
```
to ignore the `:metabase/disallow-hardcoded-driver-names-in-tests` Linter inside the `(do-x ...)` form. You do not
need to point these out to us, we know what they do.
```clj
^{:clj-kondo/ignore [:metabase/disallow-hardcoded-driver-names-in-tests]}
(do-x ...)
```
is an acceptable alternative.
- Avoid ignoring **everything** with a `#_:clj-kondo/ignore` (a keyword instead of a map).
- **[docs-write](.claude/skills/docs-write/SKILL.md)** - Documentation writing with Metabase style guide
- **[docs-review](.claude/skills/docs-review/SKILL.md)** - Documentation review checklist

View File

@@ -20,8 +20,9 @@ const REPLACES_MAP = {
"metabase-types": "frontend/src/metabase-types",
metabase: "frontend/src/metabase",
"embedding-sdk-package": "enterprise/frontend/src/embedding-sdk-package",
"embedding-sdk-bundle": "enterprise/frontend/src/embedding-sdk-bundle",
"embedding-sdk-shared": "enterprise/frontend/src/embedding-sdk-shared",
"embedding-sdk-bundle": "frontend/src/embedding-sdk-bundle",
"embedding-sdk-shared": "frontend/src/embedding-sdk-shared",
"embedding-sdk-ee": "enterprise/frontend/src/embedding-sdk-ee",
cljs: "target/cljs_release",
};
@@ -31,13 +32,15 @@ const API_EXTRACTOR_CONFIG_PATH = path.join(
);
const getLogger = (prefix) => {
const verbose = process.env.SDK_FIXUP_VERBOSE_LOGS === "true";
return {
verbose: (message) => verbose && console.log(`[${prefix}] ${message}`),
log: (message) => console.log(`[${prefix}] ${message}`),
error: (message) => console.error(`[${prefix}] ${message}`),
};
};
const { log } = getLogger("dts fixup");
const { log, verbose } = getLogger("dts fixup");
const getRelativePath = (fromPath, toPath) => {
const relativePath = path.relative(path.dirname(fromPath), toPath);
@@ -67,7 +70,7 @@ const replaceAliasedImports = (filePath) => {
});
fs.writeFileSync(filePath, fileContent, { encoding: "utf-8" });
log(`Edited file: ${filePath}`);
verbose(`Edited file: ${filePath}`);
};
const removeUnresolvedReexports = (filePath) => {
@@ -94,7 +97,7 @@ const removeUnresolvedReexports = (filePath) => {
!fs.existsSync(`${path.resolve(dirPath, target)}.d.ts`);
if (isUnresolved) {
log(`Removing unresolved re-export: ${line}`);
verbose(`Removing unresolved re-export: ${line}`);
isModified = true;
}
@@ -117,7 +120,7 @@ const fixupTypesAfterCompilation = ({ isWatchMode }) => {
removeUnresolvedReexports(filePath);
});
log("Done!");
console.log("[dts fixup] Done!");
if (!isWatchMode) {
generateDtsRollup();
@@ -176,7 +179,7 @@ const generateDtsRollup = () => {
});
});
log("Dts rollup done!");
console.log("[dts rollup] Dts rollup done!");
};
const watchFilesAndFixThem = () => {

View File

@@ -74,7 +74,7 @@
:exclusions [commons-beanutils/commons-beanutils
commons-digester/commons-digester
commons-logging/commons-logging]}
compojure/compojure {:mvn/version "1.7.1" ; HTTP Routing library built on Ring
compojure/compojure {:mvn/version "1.7.2" ; HTTP Routing library built on Ring
:exclusions [ring/ring-codec]}
crypto-random/crypto-random {:mvn/version "1.2.1"} ; library for generating cryptographically secure random bytes and strings
diehard/diehard {:mvn/version "0.12.0"}
@@ -87,7 +87,7 @@
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
io.github.camsaul/toucan2 {:mvn/version "1.0.569"}
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
:git/sha "4998a1fdd9d3713d7034d04509e9212204773bf2"
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
:git/url "https://github.com/eerohele/pp"}
;; TODO: replace back to normal artifact
io.github.metabase/macaw {:mvn/version "0.2.29"} ; Parse native SQL queries
@@ -108,7 +108,7 @@
org.bouncycastle/bcprov-jdk18on]}
metabase/throttle {:mvn/version "1.0.2"} ; Tools for throttling access to API endpoints and other code pathways
methodical/methodical {:mvn/version "1.0.127"} ; drop-in replacements for Clojure multimethods and adds several advanced features
metosin/malli {:mvn/version "0.19.2"} ; Data-driven Schemas for Clojure/Script and babashka
metosin/malli {:mvn/version "0.20.0"} ; Data-driven Schemas for Clojure/Script and babashka
nano-id/nano-id {:mvn/version "1.1.0"} ; NanoID generator for generating entity_ids
net.cgrand/macrovich {:mvn/version "0.2.2"} ; utils for writing macros for both Clojure & ClojureScript
net.clojars.wkok/openai-clojure {:mvn/version "0.23.0"
@@ -138,7 +138,7 @@
org.apache.sshd/sshd-core {:mvn/version "3.0.0-M2" ; ssh tunneling and test server
:exclusions [org.slf4j/slf4j-api
org.slf4j/jcl-over-slf4j]}
org.apache.tika/tika-core {:mvn/version "3.2.2"}
org.apache.tika/tika-core {:mvn/version "3.2.3"}
org.apache.xmlgraphics/batik-all {:mvn/version "1.19"} ; SVG -> image
org.bouncycastle/bcpkix-jdk18on {:mvn/version "1.82"} ; Bouncy Castle crypto library -- explicit version of BC specified to resolve illegal reflective access errors
org.bouncycastle/bcprov-jdk18on {:mvn/version "1.82"}
@@ -180,7 +180,7 @@
org.mariadb.jdbc/mariadb-java-client ^:antq/exclude ; 3.x only support jdbc:mariadb, so using 2.x for now
{:mvn/version "2.7.10"} ; MySQL/MariaDB driver
org.mindrot/jbcrypt {:mvn/version "0.4"} ; Crypto library
org.postgresql/postgresql {:mvn/version "42.7.7"} ; Postgres driver
org.postgresql/postgresql {:mvn/version "42.7.8"} ; Postgres driver
org.slf4j/slf4j-api {:mvn/version "2.0.17"} ; abstraction for logging frameworks -- allows end user to plug in desired logging framework at deployment time
org.tcrawley/dynapath {:mvn/version "1.1.0"} ; Dynamically add Jars (e.g. Oracle or Vertica) to classpath
org.threeten/threeten-extra {:mvn/version "1.8.0"} ; extra Java 8 java.time classes like DayOfMonth and Quarter
@@ -249,7 +249,7 @@
{cider/cider-nrepl {:mvn/version "0.58.0"} ; nREPL server for CIDER
clj-http-fake/clj-http-fake {:mvn/version "1.0.4"
:exclusions [slingshot/slingshot]}
clj-kondo/clj-kondo {:mvn/version "2025.07.28"} ; included here mainly to facilitate working on the stuff in `.clj-kondo/src`
clj-kondo/clj-kondo {:mvn/version "2025.10.23"} ; included here mainly to facilitate working on the stuff in `.clj-kondo/src`
cloverage/cloverage {:mvn/version "1.2.4"}
com.clojure-goes-fast/clj-async-profiler
{:mvn/version "1.6.2"} ; Enables local profiling and heat map generation
@@ -467,7 +467,7 @@
;; Use this to only run Kondo against specific files.
:kondo
{:replace-deps
{clj-kondo/clj-kondo {:mvn/version "2025.07.28"}}
{clj-kondo/clj-kondo {:mvn/version "2025.10.23"}}
:main-opts
["-m" "clj-kondo.main"]}

View File

@@ -356,8 +356,8 @@
([deps module-x module-y]
(let [module-x-ns->module-y-ns (->> (external-usages deps module-y)
(filter #(= (:module %) module-x))
(map (juxt :namespace :depends-on-namespace)))]
(filter #(= (:module %) module-x))
(map (juxt :namespace :depends-on-namespace)))]
(reduce
(fn [m [module-x-ns module-y-ns]]
(update m module-x-ns (fn [deps]

View File

@@ -3853,7 +3853,7 @@
"required" : [ "type", "subject", "body" ]
} ]
},
"metabase.collections.api.DashboardQuestionCandidate" : {
"metabase.collections-rest.api.DashboardQuestionCandidate" : {
"type" : "object",
"properties" : {
"description" : {
@@ -3885,13 +3885,13 @@
},
"required" : [ "id", "name", "sole_dashboard_info" ]
},
"metabase.collections.api.DashboardQuestionCandidatesResponse" : {
"metabase.collections-rest.api.DashboardQuestionCandidatesResponse" : {
"type" : "object",
"properties" : {
"data" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.collections.api.DashboardQuestionCandidate"
"$ref" : "#/components/schemas/metabase.collections-rest.api.DashboardQuestionCandidate"
}
},
"total" : {
@@ -3900,7 +3900,7 @@
},
"required" : [ "data", "total" ]
},
"metabase.collections.api.MoveDashboardQuestionCandidatesResponse" : {
"metabase.collections-rest.api.MoveDashboardQuestionCandidatesResponse" : {
"type" : "object",
"properties" : {
"moved" : {
@@ -9211,7 +9211,7 @@
"type" : "string",
"enum" : [ "events" ]
},
"metabase.users.api.user-group-membership" : {
"metabase.users-rest.api.user-group-membership" : {
"description" : "Group Membership info of a User.\n In which :is_group_manager is only included if `advanced-permissions` is enabled.",
"type" : "object",
"properties" : {
@@ -9267,12 +9267,12 @@
},
"required" : [ "schedule_type" ]
},
"metabase.warehouse-schema.api.table.data-authority-write" : {
"metabase.warehouse-schema-rest.api.table.data-authority-write" : {
"description" : "Schema for writing a valid table data authority.",
"type" : "string",
"enum" : [ "computed", "authoritative", "unconfigured", "ingested" ]
},
"metabase.warehouses.api.available-settings" : {
"metabase.warehouses-rest.api.available-settings" : {
"type" : "object",
"additionalProperties" : {
"type" : "object",
@@ -12530,7 +12530,7 @@
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/metabase.collections.api.DashboardQuestionCandidatesResponse"
"$ref" : "#/components/schemas/metabase.collections-rest.api.DashboardQuestionCandidatesResponse"
}
}
}
@@ -12656,7 +12656,7 @@
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/metabase.collections.api.MoveDashboardQuestionCandidatesResponse"
"$ref" : "#/components/schemas/metabase.collections-rest.api.MoveDashboardQuestionCandidatesResponse"
}
}
}
@@ -12914,7 +12914,7 @@
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/metabase.collections.api.DashboardQuestionCandidatesResponse"
"$ref" : "#/components/schemas/metabase.collections-rest.api.DashboardQuestionCandidatesResponse"
}
}
}
@@ -13049,7 +13049,7 @@
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/metabase.collections.api.MoveDashboardQuestionCandidatesResponse"
"$ref" : "#/components/schemas/metabase.collections-rest.api.MoveDashboardQuestionCandidatesResponse"
}
}
}
@@ -15119,7 +15119,7 @@
"type" : "object",
"properties" : {
"settings" : {
"$ref" : "#/components/schemas/metabase.warehouses.api.available-settings"
"$ref" : "#/components/schemas/metabase.warehouses-rest.api.available-settings"
}
},
"required" : [ "settings" ]
@@ -28814,7 +28814,7 @@
"enum" : [ "technical", "hidden", "cruft" ]
},
"data_authority" : {
"$ref" : "#/components/schemas/metabase.warehouse-schema.api.table.data-authority-write"
"$ref" : "#/components/schemas/metabase.warehouse-schema-rest.api.table.data-authority-write"
},
"show_in_getting_started" : {
"type" : "boolean"
@@ -28977,7 +28977,7 @@
"enum" : [ "technical", "hidden", "cruft" ]
},
"data_authority" : {
"$ref" : "#/components/schemas/metabase.warehouse-schema.api.table.data-authority-write"
"$ref" : "#/components/schemas/metabase.warehouse-schema-rest.api.table.data-authority-write"
},
"show_in_getting_started" : {
"type" : "boolean"
@@ -30473,7 +30473,7 @@
"user_group_memberships" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.users.api.user-group-membership"
"$ref" : "#/components/schemas/metabase.users-rest.api.user-group-membership"
}
}
},
@@ -30611,7 +30611,7 @@
"user_group_memberships" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.users.api.user-group-membership"
"$ref" : "#/components/schemas/metabase.users-rest.api.user-group-membership"
}
}
}
@@ -30772,4 +30772,4 @@
"url" : "http://localhost:3000",
"description" : "Localhost"
} ]
}
}

View File

@@ -128,7 +128,6 @@ For the most part we follow the [Airbnb React/JSX Style Guide](https://github.co
- Prefer React [function components over class components](https://reactjs.org/docs/components-and-props.html#function-and-class-components)
- Avoid creating new components within the `containers` folder, as this approach has been deprecated. Instead, store both connected and view components in the `components` folder for a more unified and efficient organization. If a connected component grows substantially in size and you need to extract a view component, opt for using the `View` suffix.
- For control components, typically we use `value` and `onChange`. Controls that have options (e.x. `Radio`, `Select`) usually take an `options` array of objects with `name` and `value` properties.
- Components named like `FooModal` and `FooPopover` typically refer to the modal/popover _content_ which should be used inside a `Modal`/`ModalWithTrigger` or `Popover`/`PopoverWithTrigger`
- Components named like `FooWidget` typically include a `FooPopover` inside a `PopoverWithTrigger` with some sort of trigger element, often `FooName`
- Use arrow function instance properties if you need to bind a method in a class (instead of `this.method = this.method.bind(this);` in the constructor), but only if the function needs to be bound (e.x. if you're passing it as a prop to a React component)

View File

@@ -4,9 +4,7 @@ title: Metabase release versioning
# Metabase release versioning
We follow our own flavor of the [semantic versioning guidelines](https://semver.org/) in order to distinguish the [open-source version](https://www.metabase.com/product/starter) of Metabase from the paid, source-available version of Metabase (available in the [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans).
Semantic versioning typically follows the format: `Major.Minor.Point.Patch`. For example, version `3.15.2` or `3.15.2.1`.
We follow our own flavor of the [semantic versioning guidelines](https://semver.org/). Standard semantic versioning uses `Major.Minor.Patch` format, but we've adapted it to distinguish the [open-source version](https://www.metabase.com/product/starter) of Metabase from the paid, source-available version of Metabase (available in the [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans).
With Metabase releases, we prefix the version with a `0` or `1`, depending on the license.
@@ -16,13 +14,13 @@ With Metabase releases, we prefix the version with a `0` or `1`, depending on th
License.Major.Point.Hotfix
```
E.g.,
For example:
```
v0.46.3.1
v0.57.3.1
```
`v0.46.3.1` would be for a hotfix (`1`) for the third (`3`) point release of Metabase `46`, the open-source edition (`0`).
`v0.57.3.1` would be for a hotfix (`1`) for the third (`3`) point release of Metabase `57`, the open-source edition (`0`).
### License
@@ -31,33 +29,39 @@ v0.46.3.1
### Major
We release major version when we introduce new features or breaking changes.
We release major versions when we introduce new features or breaking changes. See [release announcements](https://www.metabase.com/releases).
### Point
Sometimes called a minor release, we issue point releases when we add bug fixes and refinements to existing features.
Sometimes called a minor release, we issue point releases when we add bug fixes and refinements to existing features. See the [changelog](https://www.metabase.com/changelog)
### Hotfix
Sometimes called a patch release, we issue these hotfix releases to fix security issues in a timely manner, or to undo a horrific regression.
Sometimes called a patch release, we issue these releases nightly so we can fix bugs or security issues as soon as we can.
You'll see releases ending as `.x` in [releases on GitHub](https://github.com/metabase/metabase/releases). These are placeholder links. The actual link will go to the latest hotfix. For example:
- `metabase/metabase:v0.57.2.x` will automatically pull the latest patch version in for the latest minor version (like `v0.57.2.1`, `v0.57.2.2`, etc.).
## Other release terms
### The Gold Release
### The beta release
The gold release is the first release of a new major version of Metabase. So for Metabase version 46, the gold releases would be:
The beta release is the beta release of a new major version of Metabase. So for Metabase version 57, the gold releases would be:
- `v0.46.0` (the OSS version)
- `v1.46.0` (the EE version)
- `v0.57.0` (the OSS version)
- `v1.57.0` (the EE version)
### Release Candidates
### The gold release
We usually publish release candidates to kick the tires on new features before releasing a new major version (a gold release). To distinguish these release candidates, we append an `-RC#` at the end.
The gold release is the first stable release of a new major version of Metabase. So for Metabase version 57, the gold releases would be:
E.g.,
- `v0.57.1` (the OSS version)
- `v1.57.1` (the EE version)
- `v1.46.0-RC1` (the first release candidate for the EE version)
- `v0.46.0-RC3` (the third release candidate for the OSS version)
### Cloud builds
If you see a version followed by an `-X`, like `v1.56.2-X01`, these are version numbers related to builds for customers on [Metabase Cloud](https://www.metabase.com/cloud/).
## Further reading

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -5,7 +5,7 @@ summary: Create documents to combine text and charts for long-form analysis. Add
# Documents
{% include plans-blockquote.html feature="Documents" %}
{% include plans-blockquote.html feature="Documents" is_plural=true%}
![Document with comment](./images/document-with-comment.png)
@@ -64,10 +64,11 @@ Questions that are created directly in the document (either through [Metabot](..
To edit a chart, click the **three-dot** menu in the chart's upper right. You can:
- Edit visualization
- Edit query
- Replace the chart with another question or model
- [Download results](../questions/exporting-results.md#exporting-results-from-document-cards)
- **Add supporting text** to provide context next to the chart
- **Edit visualization** to change how the chart displays data
- **Edit query** to modify the underlying question
- **Replace** the chart with another question or model
- [**Download results**](../questions/exporting-results.md#exporting-results-from-document-cards)
Edits only affect the chart in the document. If you added the chart from a collection, Metabase will have created a copy of the chart. Any changes made to the chart in the document won't affect the original question.
@@ -77,16 +78,31 @@ Edits only affect the chart in the document. If you added the chart from a colle
To rearrange charts, simply click and hold on the chart and drag it to where you want it in the document.
You can put up to three charts side by side (more than three charts side-by-side gets crowded). To resize charts, you can click on the edges to adjust their size.
When there are multiple charts in the same row, you can resize them horizontally by clicking between the charts and sliding left or right to resize.
You can put up to three items side by side in a group. This includes both charts and supporting text blocks. When you have multiple items in the same row, you can resize them horizontally by clicking between the items and sliding left or right to resize.
To resize a chart's height, click the bottom of the chart and resize the chart up or down.
For now, you can only put charts side-by-side (not text elements).
After arranging your charts, save your changes!
## Adding supporting text to charts
![Supporting text next to a chart](./images/supporting-text.png)
You can add a supporting text block next to charts to provide context.
To add supporting text to a chart:
1. Hover over the chart.
2. Click the **three-dot** menu in the chart's upper right.
3. Select **Add supporting text**.
4. Type your text in the supporting text block.
You can only have one supporting text block per group of charts. A group can contain up to three items total (charts and supporting text block combined).
You can drag and drop a supporting text block to reorder them within a group of charts. You can only move supporting text within its current group; you can't drag it out to create a standalone text block.
Supporting text blocks support the same Markdown formatting as the rest of the document, including headings, lists, blockquotes, and code blocks. But you can't add charts in supporting text.
## Formatting text
Documents support Markdown syntax for:

View File

@@ -26,9 +26,11 @@ Here's the [Shoppy source code](https://github.com/metabase/shoppy).
## Quickstarts
- [Quickstart](./quickstart.md) (If you have Metabase and an app)
- [Quickstart CLI](./quickstart-cli.md) (If you have an app, but no Metabase)
- [Quickstart with sample React app](./quickstart-with-sample-app.md) (If you don't have either)
The best way to get started with Embedded Analytics SDK depends on what you already have:
- You have an app and a Metabase instance: go to [main quickstart](./quickstart.md)
- You have an app but no Metabase: go to [quickstart with CLI](./quickstart-cli.md)
- You don't have an app: go to [quickstart with a sample React app](./quickstart-with-sample-app.md)
## Installation

View File

@@ -67,7 +67,7 @@ Copy the token immediately after generating it.
### 3. Connect your development Metabase to your repository
![Development settings](./images/development-settings.png)
![Development settings](./images/read-write-settings.png)
You can put any Metabase into Read-write mode. We also offer [Development instances](./development-instance.md) that you can use for Remote Sync or any other kind of development.
@@ -130,7 +130,7 @@ Copy the token immediately after generating it — you'll need to paste it into
### 7. Connect your production Metabase to your repository
![Production settings](./images/production-settings.png)
![Production settings](./images/read-only-settings.png)
In your production Metabase instance:
@@ -156,7 +156,7 @@ In your production Metabase instance:
In Read-only mode, the synced collection appears in the regular collections list with a special icon to indicate it's versioned and read-only.
![Production Metabase](./images/production-view.png)
![Production Metabase](./images/read-only-view.png)
At this point, you should be all set up. Exit Admin settings, then reload your browser. You should see your synced collection in your production Metabase.

View File

@@ -150,6 +150,10 @@ you could put the snippet in one query and have the snippet parameter map to a `
If you have multiple snippets containing parameters with the same name, the question using those snippets will only use one instance of the parameter. For example, if `{% raw %}{{snippet: 1}}{% endraw %}` contains parameter `{% raw %}{{var}}{% endraw %}` and `{% raw %}{{snippet: 2}}{% endraw %}` also contains parameter `{% raw %}{{var}}{% endraw %}`, the question will display only one `{% raw %}{{var}}{% endraw %}` parameter and use its value in both snippets.
### Sharing parameters across questions
You can also use snippets to share parameters across multiple SQL questions, including questions that build on one another (nested questions). For example, you have a question “Orders by date” that filters orders using `{% raw %}{{start_date}}{% endraw %}`. You then create another question, say “Revenue by product,” that uses the results from “Orders by date.” To keep using the same `{% raw %}{{start_date}}{% endraw %}` parameter in both questions, move the SQL that contains the parameter from “Orders by date” into a snippet and update both questions to reference that snippet. Now, both questions surface the same parameter, and a single dashboard date filter can control both cards that use the snippet.
## Snippet permissions
Any user who has native editor permissions to at least one of your connected databases will be able to view the snippets sidebar, and will be able to create, edit, and archive or unarchive any and all snippets — even snippets intended to be used with databases the user lacks SQL editing access to.

View File

@@ -1 +1,3 @@
export const BACKEND_PORT = process.env.BACKEND_PORT ?? 4000;
export const BACKEND_HOST = process.env.MB_JETTY_HOST || "localhost";
export const BACKEND_PORT =
process.env.MB_JETTY_PORT || process.env.BACKEND_PORT || 4000;

View File

@@ -0,0 +1,40 @@
const cypress = require("cypress");
const { FAILURE_EXIT_CODE } = require("./constants/exit-code");
/**
* This simply runs Cypress through the Cypress Module API rather than the CLI.
* See: https://docs.cypress.io/app/references/module-api
*
* @param {Partial<CypressCommandLine.CypressRunOptions> | Partial<CypressCommandLine.CypressOpenOptions>} options - Cypress configuration options for running or opening tests
* @param {function(number): Promise<void>} exitFunction - Async function to call on exit with the exit code
* @returns {Promise<CypressCommandLine.CypressRunResult | CypressCommandLine.CypressFailedRunResult | void>}
* @throws {Error} When Cypress fails to run tests
*/
const runCypress = async (options, exitFunction) => {
const openMode = process.env.OPEN_UI === "true";
try {
const { status, message, totalFailed, failures } = openMode
? await cypress.open(options)
: await cypress.run(options);
// At least one test failed
if (totalFailed > 0) {
await exitFunction(FAILURE_EXIT_CODE);
}
// Something went wrong and Cypress failed to even run tests
if (status === "failed" && failures) {
console.error(message);
await exitFunction(failures);
}
} catch (e) {
console.error("Failed to run Cypress!\n", e);
await exitFunction(FAILURE_EXIT_CODE);
}
};
module.exports = runCypress;

View File

@@ -6,18 +6,18 @@ const http = require("http");
const os = require("os");
const path = require("path");
const { BACKEND_HOST, BACKEND_PORT } = require("./constants/backend-port");
const { delay } = require("./cypress-runner-utils");
const CypressBackend = {
server: null,
createServer(port = process.env.BACKEND_PORT || 4000) {
createServer(port = BACKEND_PORT) {
const generateTempDbPath = () =>
path.join(os.tmpdir(), `metabase-test-${process.pid}.db`);
const server = {
dbFile: generateTempDbPath(),
host: `http://localhost:${port}`,
port,
};
this.server = server;
@@ -41,8 +41,8 @@ const CypressBackend = {
const metabaseConfig = {
MB_DB_TYPE: "h2",
MB_DB_FILE: this.server.dbFile,
MB_JETTY_HOST: "0.0.0.0",
MB_JETTY_PORT: this.server.port,
MB_JETTY_HOST: BACKEND_HOST,
MB_JETTY_PORT: BACKEND_PORT,
MB_ENABLE_TEST_ENDPOINTS: "true",
MB_DANGEROUS_UNSAFE_ENABLE_TESTING_H2_CONNECTIONS_DO_NOT_ENABLE: "true",
MB_LAST_ANALYTICS_CHECKSUM: "-1",

View File

@@ -1,174 +0,0 @@
const cypress = require("cypress");
const { BACKEND_PORT } = require("./constants/backend-port");
const { FAILURE_EXIT_CODE } = require("./constants/exit-code");
const { parseArguments, args } = require("./cypress-runner-utils");
const {
HOST_APP_SETUP_CONFIGS,
} = require("./embedding-sdk/host-apps/constants/host-app-setup-configs");
const {
SAMPLE_APP_SETUP_CONFIGS,
} = require("./embedding-sdk/sample-apps/constants/sample-app-setup-configs");
const getHost = (port = BACKEND_PORT) => `http://localhost:${port}`;
const getEmbeddingSdkAppE2eConfig = async ({
baseUrl,
env,
project,
specPattern,
}) => {
process.env = {
...process.env,
...env,
};
const defaultConfig = {
browser: process.env.CYPRESS_BROWSER ?? "chrome",
project,
configFile: "e2e/support/cypress.config.js",
config: {
baseUrl,
specPattern,
env,
},
testingType: "e2e",
openMode: args["--open"] || process.env.OPEN_UI === "true",
};
const userArgs = await parseArguments(args);
return Object.assign({}, defaultConfig, userArgs);
};
const getSampleAppE2eConfig = (suite) => ({
[suite]: async () => {
const { appName, env } = SAMPLE_APP_SETUP_CONFIGS[suite];
const { CLIENT_PORT } = env;
return getEmbeddingSdkAppE2eConfig({
// If the `clientPort` is not set, it means we have multiple apps running on different ports,
// so we control the `baseUrl` based on other `env` variables on the Sample App tests level.
baseUrl: CLIENT_PORT ? getHost(CLIENT_PORT) : "",
env,
project: ["e2e/tmp", appName].join("/"),
});
},
});
const getHostAppE2eConfig = (suite) => ({
[suite]: async () => {
const { appName, env } = HOST_APP_SETUP_CONFIGS[suite];
return getEmbeddingSdkAppE2eConfig({
baseUrl: getHost(),
env,
specPattern: [
"e2e/test-host-app/shared/**/*.cy.spec.{js,ts}",
["e2e/test-host-app", appName, "**/*.cy.spec.{js,ts}"].join("/"),
],
});
},
});
// This is a map of all possible Cypress configurations we can run.
const configs = {
e2e: async () => {
const defaultConfig = {
browser: process.env.CYPRESS_BROWSER ?? "chrome",
configFile: "e2e/support/cypress.config.js",
config: {
baseUrl: getHost(),
},
testingType: "e2e",
openMode: args["--open"] || process.env.OPEN_UI === "true",
};
const userArgs = await parseArguments(args);
const finalConfig = Object.assign({}, defaultConfig, userArgs);
return finalConfig;
},
...getSampleAppE2eConfig("metabase-nodejs-react-sdk-embedding-sample-e2e"),
...getSampleAppE2eConfig("metabase-nextjs-sdk-embedding-sample-e2e"),
...getSampleAppE2eConfig("shoppy-e2e"),
...getHostAppE2eConfig("vite-6-host-app-e2e"),
...getHostAppE2eConfig("next-15-app-router-host-app-e2e"),
...getHostAppE2eConfig("next-15-pages-router-host-app-e2e"),
...getHostAppE2eConfig("angular-20-host-app-e2e"),
snapshot: async () => {
// We only ever care about a browser out of all possible user arguments,
// when it comes to the snapshot generation.
// Anything else could result either in a failure or in a wrong database snapshot!
const { browser } = await parseArguments(args);
const snapshotConfig = {
browser: browser ?? process.env.CYPRESS_BROWSER ?? "chrome",
configFile: "e2e/support/cypress-snapshots.config.js",
config: {
baseUrl: getHost(),
},
testingType: "e2e",
openMode: false,
};
return snapshotConfig;
},
component: async () => {
const { browser } = await parseArguments(args);
const sdkComponentConfig = {
browser: browser ?? process.env.CYPRESS_BROWSER ?? "chrome",
configFile: "e2e/support/cypress-embedding-sdk-component-test.config.js",
config: {
baseUrl: getHost(),
},
testingType: "component",
openMode: args["--open"] || process.env.OPEN_UI === "true",
};
const userArgs = await parseArguments(args);
const finalConfig = Object.assign({}, sdkComponentConfig, userArgs);
return finalConfig;
},
};
/**
* This simply runs cypress through the javascript API rather than the CLI, and
* lets us conditionally load a config file and some other options along with it.
*/
const runCypress = async (suite = "e2e", exitFunction) => {
if (!configs[suite]) {
console.error(
`Invalid suite: ${suite}, try one of: ${Object.keys(configs)}`,
);
await exitFunction(FAILURE_EXIT_CODE);
}
const config = await configs[suite]();
try {
const { status, message, totalFailed, failures } = config.openMode
? await cypress.open(config)
: await cypress.run(config);
// At least one test failed
if (totalFailed > 0) {
await exitFunction(FAILURE_EXIT_CODE);
}
// Something went wrong and Cypress failed to even run tests
if (status === "failed" && failures) {
console.error(message);
await exitFunction(failures);
}
} catch (e) {
console.error("Failed to run Cypress!\n", e);
await exitFunction(FAILURE_EXIT_CODE);
}
};
module.exports = runCypress;

View File

@@ -1,6 +1,5 @@
const { execSync, spawn } = require("child_process");
const arg = require("arg");
const chalk = require("chalk");
const cypress = require("cypress");
@@ -8,16 +7,7 @@ function printBold(message) {
console.log(`\n${chalk.bold(chalk.magenta(message.trim()))}\n`);
}
const args = arg(
{
"--open": [Boolean], // Run Cypress in open mode or not? Doesn't accept additional arguments
},
{ permissive: true }, // Passes all other flags and args to the Cypress parser
);
async function parseArguments(args) {
const cliArgs = args._;
async function parseArguments(cliArgs) {
// cypress.cli.parseArguments requires `cypress run` as the first two arguments
if (cliArgs[0] !== "cypress") {
cliArgs.unshift("cypress");
@@ -92,7 +82,6 @@ function delay(durationMs) {
}
module.exports = {
args,
booleanify,
unBooleanify,
parseArguments,

View File

@@ -0,0 +1,81 @@
const { BACKEND_PORT } = require("./constants/backend-port");
const {
HOST_APP_SETUP_CONFIGS,
} = require("./embedding-sdk/host-apps/constants/host-app-setup-configs");
const {
SAMPLE_APP_SETUP_CONFIGS,
} = require("./embedding-sdk/sample-apps/constants/sample-app-setup-configs");
const getHost = (port = BACKEND_PORT) => `http://localhost:${port}`;
const getEmbeddingSdkAppE2eConfig = ({
baseUrl,
env,
project,
specPattern,
}) => {
process.env = {
...process.env,
...env,
};
return {
project,
configFile: "e2e/support/cypress.config.js",
config: {
baseUrl,
specPattern,
env,
},
};
};
const getSampleAppE2eConfig = (suite) => ({
[suite]: () => {
const { appName, env } = SAMPLE_APP_SETUP_CONFIGS[suite];
const { CLIENT_PORT } = env;
return getEmbeddingSdkAppE2eConfig({
// If the `clientPort` is not set, it means we have multiple apps running on different ports,
// so we control the `baseUrl` based on other `env` variables on the Sample App tests level.
baseUrl: CLIENT_PORT ? getHost(CLIENT_PORT) : "",
env,
project: ["e2e/tmp", appName].join("/"),
});
},
});
const getHostAppE2eConfig = (suite) => ({
[suite]: () => {
const { env } = HOST_APP_SETUP_CONFIGS[suite];
return getEmbeddingSdkAppE2eConfig({
baseUrl: getHost(),
env,
specPattern: "e2e/test-host-app/shared/**/*.cy.spec.{js,ts}",
});
},
});
// This is a map of all possible custom SDK configurations we can run.
const configs = {
...getSampleAppE2eConfig("metabase-nodejs-react-sdk-embedding-sample-e2e"),
...getSampleAppE2eConfig("metabase-nextjs-sdk-embedding-sample-e2e"),
...getSampleAppE2eConfig("shoppy-e2e"),
...getHostAppE2eConfig("vite-6-host-app-e2e"),
...getHostAppE2eConfig("next-15-app-router-host-app-e2e"),
...getHostAppE2eConfig("next-15-pages-router-host-app-e2e"),
...getHostAppE2eConfig("angular-20-host-app-e2e"),
};
const resolveSdkE2EConfig = (suite) => {
if (!configs[suite]) {
throw new Error(
`Invalid suite: ${suite}, try one of: ${Object.keys(configs)}`,
);
}
return configs[suite]();
};
module.exports = { resolveSdkE2EConfig };

View File

@@ -1,14 +1,15 @@
const { FAILURE_EXIT_CODE } = require("./constants/exit-code");
const runCypress = require("./cypress-node-js-runner");
const CypressBackend = require("./cypress-runner-backend");
const runCypress = require("./cypress-runner-run-tests");
const { printBold } = require("./cypress-runner-utils");
const { parseArguments, printBold } = require("./cypress-runner-utils");
const { resolveSdkE2EConfig } = require("./resolve-sdk-e2e-config");
const modeOrTestSuite = process.argv?.[2]?.trim();
const command = process.argv?.[2]?.trim();
const cliArguments = process.argv.slice(3);
const availableModes = ["start", "snapshot"];
const availableTestSuites = [
"e2e",
"component",
const availableTestingTypes = ["e2e", "component"];
const availableSDKTestSuites = [
"metabase-nodejs-react-sdk-embedding-sample-e2e",
"metabase-nextjs-sdk-embedding-sample-e2e",
"shoppy-e2e",
@@ -18,11 +19,14 @@ const availableTestSuites = [
"angular-20-host-app-e2e",
];
if (
!availableModes.includes(modeOrTestSuite) &&
!availableTestSuites.includes(modeOrTestSuite)
) {
console.error(`Invalid mode or test suite: ${modeOrTestSuite}`);
const permittedCommands = [
...availableModes,
...availableTestingTypes,
...availableSDKTestSuites,
];
if (!permittedCommands.includes(command)) {
console.error(`Invalid command: ${command}`);
process.exit(FAILURE_EXIT_CODE);
}
@@ -31,13 +35,37 @@ const startServer = async () => {
await CypressBackend.start();
};
const runTests = async (testSuite) => {
printBold(`Running ${testSuite} Cypress Tests`);
await runCypress(testSuite, process.exit);
const runTests = async (config, cliArguments = []) => {
const userOverrides = await parseArguments(cliArguments);
await runCypress({ ...config, ...userOverrides }, process.exit);
};
if (modeOrTestSuite === "start") {
// Custom "modes"
if (command === "start") {
startServer();
} else {
runTests(modeOrTestSuite);
}
if (command === "snapshot") {
runTests({ configFile: "e2e/support/cypress-snapshots.config.js" });
}
// Metabase component or e2e tests
if (command === "component") {
runTests(
{
configFile: "e2e/support/cypress-embedding-sdk-component-test.config.js",
testingType: "component",
},
cliArguments,
);
}
if (command === "e2e") {
runTests({ configFile: "e2e/support/cypress.config.js" }, cliArguments);
}
// Custom SDK Host/Sample App e2e tests
if (availableSDKTestSuites.includes(command)) {
const config = resolveSdkE2EConfig(command);
runTests(config);
}

View File

@@ -2,27 +2,31 @@ import fs from "fs";
import os from "os";
import path from "path";
import { BACKEND_PORT } from "./constants/backend-port";
import { FAILURE_EXIT_CODE, SUCCESS_EXIT_CODE } from "./constants/exit-code";
import runCypress from "./cypress-node-js-runner";
import CypressBackend from "./cypress-runner-backend";
import runCypress from "./cypress-runner-run-tests";
import {
booleanify,
parseArguments,
printBold,
shell,
unBooleanify,
} from "./cypress-runner-utils";
import { startHostAppContainers } from "./embedding-sdk/host-apps/start-host-app-containers";
import { startSampleAppContainers } from "./embedding-sdk/sample-apps/start-sample-app-containers";
import { resolveSdkE2EConfig } from "./resolve-sdk-e2e-config";
let tempSampleDBDir: string | null = null;
// if you want to change these, set them as environment variables in your shell
const userOptions = {
TEST_SUITE: "e2e", // e2e | component
CYPRESS_TESTING_TYPE: "e2e", // e2e | component
SDK_TEST_SUITE: undefined, // one of the many sample-app, or host-app Embedding SDK suites
MB_EDITION: "ee", // ee | oss
START_CONTAINERS: true,
STOP_CONTAINERS: false,
BACKEND_PORT: 4000,
BACKEND_PORT: BACKEND_PORT, // override with MB_JETTY_PORT in your env
OPEN_UI: true,
SHOW_BACKEND_LOGS: false,
GENERATE_SNAPSHOTS: true,
@@ -61,20 +65,24 @@ if (options.MB_EDITION === "ee" && missingTokens.length > 0) {
}
printBold(`Running Cypress with options:
- TEST_SUITE : ${options.TEST_SUITE}
- MB_EDITION : ${options.MB_EDITION}
- START_CONTAINERS : ${options.START_CONTAINERS}
- STOP_CONTAINERS : ${options.STOP_CONTAINERS}
- BUILD_JAR : ${options.BUILD_JAR}
- GENERATE_SNAPSHOTS : ${options.GENERATE_SNAPSHOTS}
- BACKEND_PORT : ${options.BACKEND_PORT}
- START_BACKEND : ${options.START_BACKEND}
- OPEN_UI : ${options.OPEN_UI}
- SHOW_BACKEND_LOGS : ${options.SHOW_BACKEND_LOGS}
- TZ : ${options.TZ}
- CYPRESS_TESTING_TYPE : ${options.CYPRESS_TESTING_TYPE}
- SDK_TEST_SUITE : ${options.SDK_TEST_SUITE}
- MB_EDITION : ${options.MB_EDITION}
- START_CONTAINERS : ${options.START_CONTAINERS}
- STOP_CONTAINERS : ${options.STOP_CONTAINERS}
- BUILD_JAR : ${options.BUILD_JAR}
- GENERATE_SNAPSHOTS : ${options.GENERATE_SNAPSHOTS}
- BACKEND_PORT : ${options.BACKEND_PORT}
- START_BACKEND : ${options.START_BACKEND}
- OPEN_UI : ${options.OPEN_UI}
- SHOW_BACKEND_LOGS : ${options.SHOW_BACKEND_LOGS}
- TZ : ${options.TZ}
`);
const init = async () => {
const cliArguments = process.argv.slice(2);
const userOverrides = await parseArguments(cliArguments);
if (options.START_CONTAINERS) {
printBold("⏳ Starting containers");
shell("docker compose -f ./e2e/test/scenarios/docker-compose.yml up -d");
@@ -121,37 +129,64 @@ const init = async () => {
// reset cache
shell("rm -f e2e/support/cypress_sample_instance_data.json");
printBold("⏳ Generating snapshots");
await runCypress("snapshot", cleanup);
printBold("⏳ Generating app db snapshots");
process.env.OPEN_UI = "false";
await runCypress(
{ configFile: "e2e/support/cypress-snapshots.config.js" },
cleanup,
);
process.env.OPEN_UI = `${options.OPEN_UI}`;
} else {
printBold("Skipping snapshot generation, beware of stale snapshot caches");
shell("echo 'Existing snapshots:' && ls -1 e2e/snapshots");
}
const isFrontendRunning = shell("lsof -ti:8080 || echo ''", { quiet: true });
if (!isFrontendRunning && options.TEST_SUITE === "e2e") {
if (!isFrontendRunning && options.CYPRESS_TESTING_TYPE === "e2e") {
printBold(
"⚠️⚠️ You don't have your frontend running. You should probably run yarn build-hot ⚠️⚠️",
);
}
switch (options.TEST_SUITE) {
case "metabase-nodejs-react-sdk-embedding-sample-e2e":
case "metabase-nextjs-sdk-embedding-sample-e2e":
case "shoppy-e2e":
await startSampleAppContainers(options.TEST_SUITE);
break;
if (options.SDK_TEST_SUITE) {
switch (options.SDK_TEST_SUITE) {
case "metabase-nodejs-react-sdk-embedding-sample-e2e":
case "metabase-nextjs-sdk-embedding-sample-e2e":
case "shoppy-e2e":
await startSampleAppContainers(options.SDK_TEST_SUITE);
break;
case "vite-6-host-app-e2e":
case "next-15-app-router-host-app-e2e":
case "next-15-pages-router-host-app-e2e":
case "angular-20-host-app-e2e":
await startHostAppContainers(options.TEST_SUITE);
break;
case "vite-6-host-app-e2e":
case "next-15-app-router-host-app-e2e":
case "next-15-pages-router-host-app-e2e":
case "angular-20-host-app-e2e":
await startHostAppContainers(options.SDK_TEST_SUITE);
break;
}
printBold("⏳ Starting Sample/Host App Cypress Tests");
const config = resolveSdkE2EConfig(options.SDK_TEST_SUITE);
await runCypress(config, cleanup);
}
printBold("⏳ Starting Cypress");
await runCypress(options.TEST_SUITE, cleanup);
if (options.CYPRESS_TESTING_TYPE === "component") {
printBold("⏳ Starting Cypress SDK component tests");
await runCypress(
{
configFile:
"e2e/support/cypress-embedding-sdk-component-test.config.js",
testingType: "component",
},
cleanup,
);
}
if (options.CYPRESS_TESTING_TYPE === "e2e") {
const config = { configFile: "e2e/support/cypress.config.js" };
printBold("⏳ Starting Cypress");
await runCypress({ ...config, ...userOverrides }, cleanup);
}
};
const cleanup = async (exitCode: string | number = SUCCESS_EXIT_CODE) => {

View File

@@ -4,6 +4,8 @@ import path from "node:path";
import cypressOnFix from "cypress-on-fix";
import installLogsPrinter from "cypress-terminal-report/src/installLogsPrinter";
import { BACKEND_HOST, BACKEND_PORT } from "../runner/constants/backend-port";
import * as ciTasks from "./ci_tasks";
import { collectFailingTests } from "./collectFailedTests";
import {
@@ -27,8 +29,6 @@ const isCI = process.env["CYPRESS_CI"] === "true";
const snowplowMicroUrl = process.env["MB_SNOWPLOW_URL"];
const isQaDatabase = process.env["QA_DB_ENABLED"] === "true";
// docs say that tsconfig paths should handle aliases, but they don't
const assetsResolverPlugin = {
name: "assetsResolver",
@@ -123,14 +123,11 @@ const defaultConfig = {
** CONFIG **
********************************************************************/
if (!isQaDatabase) {
config.excludeSpecPattern = "e2e/snapshot-creators/qa-db.cy.snap.js";
}
// `grepIntegrationFolder` needs to point to the root!
// See: https://github.com/cypress-io/cypress/issues/24452#issuecomment-1295377775
config.env.grepIntegrationFolder = "../../";
config.env.grepFilterSpecs = true;
config.env.grepOmitFiltered = true;
config.env.IS_ENTERPRISE = isEnterprise;
config.env.SNOWPLOW_MICRO_URL = snowplowMicroUrl;
@@ -156,6 +153,8 @@ const defaultConfig = {
return config;
},
baseUrl: `http://${BACKEND_HOST}:${BACKEND_PORT}`,
defaultBrowser: process.env.CYPRESS_BROWSER ?? "chrome",
supportFile: "e2e/support/cypress.js",
chromeWebSecurity: false,
modifyObstructiveCode: false,
@@ -203,19 +202,9 @@ const mainConfig = {
},
};
const snapshotsConfig = {
...defaultConfig,
specPattern: "e2e/snapshot-creators/**/*.cy.snap.js",
video: false,
};
const stressTestConfig = {
...defaultConfig,
retries: 0,
};
const embeddingSdkComponentTestConfig = {
...defaultConfig,
baseUrl: undefined, // baseUrl should not be set for component tests,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
video: false,
@@ -235,8 +224,7 @@ const embeddingSdkComponentTestConfig = {
};
module.exports = {
defaultConfig,
mainConfig,
snapshotsConfig,
stressTestConfig,
embeddingSdkComponentTestConfig,
};

View File

@@ -1,5 +1,8 @@
const { embeddingSdkComponentTestConfig } = require("./config");
module.exports = {
component: embeddingSdkComponentTestConfig,
component: {
...embeddingSdkComponentTestConfig,
baseUrl: undefined, // baseUrl is not a valid *component* config option,
},
};

View File

@@ -1,5 +1,16 @@
const { defineConfig } = require("cypress");
const { snapshotsConfig } = require("./config");
const { defaultConfig } = require("./config");
module.exports = defineConfig({ e2e: snapshotsConfig });
const isQaDatabase = process.env["QA_DB_ENABLED"] === "true";
module.exports = defineConfig({
e2e: {
...defaultConfig,
specPattern: "e2e/snapshot-creators/**/*.cy.snap.js",
excludeSpecPattern: !isQaDatabase
? "e2e/snapshot-creators/qa-db.cy.snap.js"
: undefined,
video: false,
},
});

View File

@@ -1,11 +1,8 @@
const { defineConfig } = require("cypress");
const {
stressTestConfig,
embeddingSdkComponentTestConfig,
} = require("./config");
const { defaultConfig, embeddingSdkComponentTestConfig } = require("./config");
module.exports = defineConfig({
e2e: stressTestConfig,
e2e: { ...defaultConfig, retries: 0 },
component: { ...embeddingSdkComponentTestConfig, retries: 0 },
});

View File

@@ -163,3 +163,88 @@ export const DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS = {
],
type: "doc",
};
export const DOCUMENT_WITH_SUPPORTING_TEXT = {
type: "doc",
content: [
{
type: "resizeNode",
attrs: {
height: 350,
minHeight: 280,
_id: "1",
},
content: [
{
type: "flexContainer",
attrs: {
_id: "1a",
columnWidths: [33.33333333333333, 66.66666666666666],
},
content: [
{
type: "supportingText",
attrs: { _id: "1b" },
content: [
{
type: "paragraph",
attrs: { _id: "1c" },
content: [
{
type: "text",
text: "Lorem ipsum",
},
],
},
],
},
{
type: "cardEmbed",
attrs: {
id: ORDERS_QUESTION_ID,
name: null,
_id: "2a2",
},
},
],
},
],
},
{
type: "paragraph",
content: [
{
type: "text",
text: "Standalone card below",
},
],
attrs: {
_id: "3",
},
},
{
type: "resizeNode",
attrs: {
height: 350,
minHeight: 280,
_id: "4",
},
content: [
{
type: "cardEmbed",
attrs: {
id: ORDERS_COUNT_QUESTION_ID,
name: null,
_id: "4a",
},
},
],
},
{
type: "paragraph",
attrs: {
_id: "5",
},
},
],
};

View File

@@ -59,19 +59,6 @@ export function showDashboardCardActions(index = 0) {
return getDashboardCard(index).realHover({ scrollBehavior: "bottom" });
}
/**
* Given a dashcard HTML element, will return the element for the action icon
* with the given label text (e.g. "Click behavior", "Replace", "Duplicate", etc)
*/
export function findDashCardAction(
dashcardElement: Cypress.Chainable<JQuery<HTMLElement>>,
labelText: string,
) {
return dashcardElement
.realHover({ scrollBehavior: "bottom" })
.findByLabelText(labelText);
}
export function removeDashboardCard(index = 0) {
getDashboardCard(index)
.realHover()
@@ -151,9 +138,10 @@ export function setDashCardFilter(
subType?: string,
name?: string,
) {
findDashCardAction(getDashboardCard(dashcardIndex), "Add a filter").click({
force: true,
});
getDashboardCard(dashcardIndex)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Add a filter")
.click({ force: true });
_setFilter(type, subType, name);
}

View File

@@ -109,33 +109,33 @@ const visitDocumentById = (id: DocumentId) => {
cy.wait(`@${alias}`);
};
export function dragAndDropCardOnAnotherCard(
sourceCardTitle: string,
targetCardTitle: string,
{
side = "left",
}: {
side?: "left" | "right";
} = {},
) {
// Perform drag and drop: drag sourceCard card onto targetCard
getDocumentCard(sourceCardTitle)
export function documentsDragAndDrop({
getSource,
getTarget,
side = "left",
}: {
getSource: () => Cypress.Chainable;
getTarget: () => Cypress.Chainable;
side?: "left" | "right";
}) {
// Perform drag and drop: drag source onto target
getSource()
.should("exist")
.then(($ordersCard) => {
getDocumentCard(targetCardTitle)
.then(($source) => {
getTarget()
.should("exist")
.then(($ordersCountCard) => {
const sourceRect = $ordersCard[0].getBoundingClientRect();
.then(($target) => {
const sourceRect = $source[0].getBoundingClientRect();
// Start drag from center of Orders card
getDocumentCard(sourceCardTitle).trigger("mousedown", {
// Start drag from center of source
getSource().trigger("mousedown", {
x: 10,
y: 10,
scrollBehavior: false,
force: true,
});
const dataTransfer = new DataTransfer();
getDocumentCard(sourceCardTitle).trigger("dragstart", {
getSource().trigger("dragstart", {
dataTransfer,
clientX: sourceRect.left + 10,
clientY: sourceRect.top + 10,
@@ -143,8 +143,8 @@ export function dragAndDropCardOnAnotherCard(
force: true,
});
const targetRect = $ordersCountCard[0].getBoundingClientRect();
// Calculate position for target card side drop (20% from left or 20% from right based on 'side' param)
const targetRect = $target[0].getBoundingClientRect();
// Calculate position for target side drop (20% from left or 20% from right based on 'side' param)
const sideX =
targetRect.left + targetRect.width * (side === "left" ? 0.2 : 0.8);
const centerY = targetRect.top + 10;
@@ -153,24 +153,24 @@ export function dragAndDropCardOnAnotherCard(
clientY: centerY,
scrollBehavior: false,
});
getDocumentCard(targetCardTitle).trigger("dragover", {
getTarget().trigger("dragover", {
clientX: sideX,
clientY: centerY,
scrollBehavior: false,
force: true,
});
getDocumentCard(sourceCardTitle).realMouseUp({
getSource().realMouseUp({
scrollBehavior: false,
});
getDocumentCard(targetCardTitle).trigger("drop", {
getTarget().trigger("drop", {
dataTransfer,
clientX: sideX,
clientY: centerY,
scrollBehavior: false,
force: true,
});
getDocumentCard(sourceCardTitle).trigger("dragend", {
getSource().trigger("dragend", {
scrollBehavior: false,
force: true,
});
@@ -178,6 +178,20 @@ export function dragAndDropCardOnAnotherCard(
});
}
export function dragAndDropCardOnAnotherCard(
sourceCardTitle: string,
targetCardTitle: string,
options: {
side?: "left" | "right";
} = {},
) {
return documentsDragAndDrop({
getSource: () => getDocumentCard(sourceCardTitle),
getTarget: () => getDocumentCard(targetCardTitle),
...options,
});
}
export function documentUndo() {
const macOSX = Cypress.platform === "darwin";

View File

@@ -0,0 +1,105 @@
import { getIframeBody } from "./e2e-embedding-helpers";
/**
* Base interface for interactive embedding test page options
*/
interface BaseEmbedTestPageOptions {
dashboardId: number | string;
iframeSelector?: string;
}
/**
* Creates and loads a test fixture for interactive iframe embedding tests
*/
export function loadInteractiveIframeEmbedTestPage({
iframeSelector,
...options
}: BaseEmbedTestPageOptions) {
const testPageSource = getInteractiveHtml(options);
const testPageUrl = `${origin}/interactive-iframe-test-page`;
cy.intercept("GET", testPageUrl, {
body: testPageSource,
headers: { "content-type": "text/html" },
}).as("dynamicPage");
cy.visit(testPageUrl);
cy.title().should("include", "Metabase Embed Test");
return getIframeBody(iframeSelector);
}
interface PostMessageOptions {
/**
* CSS selector for the iframe to post the message to
* @example `iframe[src*="localhost:4000/dashboard"]`
*/
iframeSelector: string;
/**
* Something like `{ metabase: { type: "location", location: "/dashboard/9999990" } }` for instance
*/
messageData: any;
}
export function postMessageToIframe(options: PostMessageOptions) {
const { messageData, iframeSelector } = options;
// this madness is necessary to simulate a real MessageEvent coming from the parent window
// because in frontend/src/metabase/lib/embed.js we check e.source === window.parent
cy.get(iframeSelector)
.should("be.visible")
.then(($iframe) => {
const iframeEl = $iframe[0] as HTMLIFrameElement;
// Get the actual parent window from the iframe's perspective
const actualParent = iframeEl.ownerDocument.defaultView;
const targetWin = iframeEl.contentWindow;
if (!actualParent || !targetWin) {
throw new Error("Could not access iframe or its parent window");
}
// Create a MessageEvent manually with the correct source
const event = new MessageEvent("message", {
data: messageData,
origin: actualParent.location.origin,
source: actualParent,
});
targetWin.dispatchEvent(event);
});
return getIframeBody(iframeSelector);
}
/**
* Base HTML template for embedding test pages
*/
function getInteractiveHtml({ dashboardId }: BaseEmbedTestPageOptions) {
const MB_BASE_URL = "http://localhost:4000";
const url = `${MB_BASE_URL}/dashboard/${dashboardId}`;
return `
<!DOCTYPE html>
<html>
<head>
<title>Metabase Embed Test</title>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<iframe
src="${url}"
style="position: absolute; height: 100%; width:99%; border: none; border: 2px solid red"
allowtransparency
></iframe>
</body>
</html>
`;
}

View File

@@ -23,6 +23,7 @@ export * from "./e2e-dragndrop-helpers";
export * from "./e2e-element-visibility-helpers";
export * from "./e2e-email-helpers";
export * from "./e2e-embedding-helpers";
export * from "./e2e-embedding-iframe-interactive-helpers";
export * from "./e2e-embedding-iframe-sdk-helpers";
export * from "./e2e-embedding-sdk-assertion-helpers";
export * from "./e2e-enterprise-helpers";

View File

@@ -318,6 +318,23 @@ describe("scenarios > embedding-sdk > interactive-dashboard", () => {
cy.wait("@dashcardDownload").then((interception) => {
expect(interception.response?.statusCode).to.equal(200);
cy.log(
"content-disposition is allowed in cross-origin requests (metabase#61708)",
);
expect(
interception.response?.headers?.["access-control-expose-headers"],
).to.include("Content-Disposition");
cy.log(
"question name is prefixed in the downloaded file name (metabase#61708)",
);
expect(
interception.response?.headers?.["content-disposition"],
).to.include('filename="orders_');
expect(
interception.response?.headers?.["content-disposition"],
).not.to.include('filename="query_result_');
});
});
});

View File

@@ -148,8 +148,7 @@ describe(
assertScoreFormPrefilled(firstScoreRow);
});
});
cy.icon("close").click();
cy.button("Close").click();
});
objectDetailModal().icon("close").click();

View File

@@ -597,7 +597,7 @@ describe("Remote Sync", () => {
cy.visit("/admin/settings/remote-sync");
cy.findByLabelText("Sync branch").clear().type("test");
cy.button("Save changes").click();
cy.findByTestId("remote-sync-submit-button").click();
cy.findByTestId("admin-layout-content")
.findByText("Success")
@@ -609,7 +609,7 @@ describe("Remote Sync", () => {
H.waitForTask({ taskName: "import" });
cy.findByRole("button", { name: "Save changes" }).should("be.disabled");
cy.findByTestId("remote-sync-submit-button").should("be.disabled");
cy.visit("/");

View File

@@ -0,0 +1,170 @@
import dayjs from "dayjs";
const { H } = cy;
describe("scenarios > admin > tools > help", { tags: "@OSS" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("should link `Get help` to help", () => {
cy.visit("/admin/tools/help");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Metabase Admin");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Get help")
.parents("a")
.should("have.prop", "href")
.and(
"match",
/^https:\/\/www\.metabase\.com\/help\?utm_source=in-product&utm_medium=troubleshooting&utm_campaign=help&instance_version=v(?:(?!diag=).)+$/,
);
});
});
describe("scenarios > admin > tools > help (EE)", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("should link `Get Help` to help-premium", () => {
cy.visit("/admin/tools/help");
cy.findByTestId("admin-layout-content")
.findByText("Get help")
.parents("a")
.should("have.prop", "href")
.and(
"match",
/^https:\/\/www\.metabase\.com\/help-premium\?utm_source=in-product&utm_medium=troubleshooting&utm_campaign=help&instance_version=v.+&diag=%7B.+%7D$/,
);
});
});
describe("scenarios > admin > tools > help > helping hand", () => {
const executeCreateGrantAccessFlow = (
durationOption?: "96 hours" | "48 hours" | "24 hours",
ticket?: string,
notes?: string,
) => {
cy.findByTestId("access-grant-list-table").should("not.exist");
cy.button("Request a helping hand").should("be.enabled");
cy.button("Request a helping hand").click();
// Clicking the button should open the modal
cy.findByTestId("grant-access-modal").should("be.visible");
cy.findByTestId("grant-access-modal").within(() => {
cy.findByRole("heading", { name: "Grant Access?" }).should("be.visible");
if (durationOption) {
cy.findByLabelText(/Access duration/).click();
cy.document()
.findByRole("listbox")
.findByText(new RegExp(durationOption, "g"))
.click();
}
ticket && cy.findByLabelText("Ticket").type(ticket);
notes && cy.findByLabelText("Notes").type(notes);
cy.button("Grant access").click();
});
H.undoToast()
.findByText("Access grant created successfully")
.should("be.visible");
};
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.mockSessionPropertiesTokenFeatures({ "support-users": true });
});
it("should only display the `Helping hand` section for cloud customers", () => {
cy.visit("/admin/tools/help");
cy.findByRole("heading", { name: "Helping hand" }).should("not.exist");
H.activateToken("pro-self-hosted");
cy.reload();
cy.findByRole("heading", { name: "Helping hand" }).should("not.exist");
H.activateToken("starter");
cy.reload();
cy.findByRole("heading", { name: "Helping hand" }).should("be.visible");
H.activateToken("pro-cloud");
cy.reload();
cy.findByRole("heading", { name: "Helping hand" }).should("be.visible");
});
it("should allow creating a new access grant", () => {
H.activateToken("pro-cloud");
cy.visit("/admin/tools/help");
cy.findByTestId("access-grant-list-table").should("not.exist");
executeCreateGrantAccessFlow();
cy.findByTestId("access-grant-list-table").should("be.visible");
cy.findByTestId("access-grant-list-table").within(() => {
cy.get("tbody").findAllByRole("row").should("have.length", 1);
});
});
it("allow creating an access grant with a ticket number and custom notes", () => {
H.activateToken("pro-cloud");
cy.visit("/admin/tools/help");
executeCreateGrantAccessFlow("48 hours", "TICKET-999", "Custom notes");
cy.findByTestId("access-grant-list-table").within(() => {
cy.findByRole("cell", {
name: new RegExp(dayjs().format("MMM D, YYYY"), "g"),
}).should("be.visible");
cy.findByRole("cell", { name: /TICKET-999/ }).should("be.visible");
cy.findByRole("cell", { name: /Custom notes/ }).should("be.visible");
cy.findByText(/48 hours left/).should("be.visible");
});
});
it("should disallow more than one active access grant", () => {
H.activateToken("pro-cloud");
cy.visit("/admin/tools/help");
executeCreateGrantAccessFlow();
cy.button("Request a helping hand").should("be.disabled");
cy.button("Request a helping hand")
.siblings()
.findByText("You can only have one active request at a time")
.should("be.visible");
});
it("can revoke an access grant", () => {
H.activateToken("pro-cloud");
cy.visit("/admin/tools/help");
executeCreateGrantAccessFlow();
cy.button("Request a helping hand").should("be.disabled");
H.undoToast().icon("close").click();
cy.findByTestId("access-grant-list-table")
.button("Revoke access grant")
.click();
H.modal()
.findByRole("heading", { name: "Revoke access grant?" })
.should("be.visible");
H.modal().button("Revoke").click();
H.undoToast()
.findByText("Access grant revoked successfully")
.should("be.visible");
cy.button("Request a helping hand").should("be.enabled");
});
});

View File

@@ -1,52 +1,10 @@
import dayjs from "dayjs";
import type { NativeQuestionDetails } from "e2e/support/helpers";
import { createMockTask } from "metabase-types/api/mocks";
const { H } = cy;
describe("scenarios > admin > tools > help", { tags: "@OSS" }, () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("should link `Get help` to help", () => {
cy.visit("/admin/tools/help");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Metabase Admin");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Get help")
.parents("a")
.should("have.prop", "href")
.and(
"match",
/^https:\/\/www\.metabase\.com\/help\?utm_source=in-product&utm_medium=troubleshooting&utm_campaign=help&instance_version=v(?:(?!diag=).)+$/,
);
});
});
describe("scenarios > admin > tools > help (EE)", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
});
it("should link `Get Help` to help-premium", () => {
cy.visit("/admin/tools/help");
cy.findByTestId("admin-layout-content")
.findByText("Get help")
.parents("a")
.should("have.prop", "href")
.and(
"match",
/^https:\/\/www\.metabase\.com\/help-premium\?utm_source=in-product&utm_medium=troubleshooting&utm_campaign=help&instance_version=v.+&diag=%7B.+%7D$/,
);
});
});
describe("issue 14636", () => {
const total = 57;
const limit = 50;
@@ -56,7 +14,7 @@ describe("issue 14636", () => {
* @param {(0|1)} payload.page
* @param {("first"|"second")} payload.alias
*/
function stubPageResponses({ page, alias }) {
function stubPageResponses({ page, alias }: { page: number; alias: string }) {
const offset = page * limit;
cy.intercept(
@@ -80,7 +38,7 @@ describe("issue 14636", () => {
* @param {(0|1)} page
* @returns Row[]
*/
function stubPageRows(page) {
function stubPageRows(page: number) {
// There rows details don't really matter.
// We're generating two types of rows. One for each page.
const tasks = ["field values scanning", "analyze"];
@@ -103,11 +61,10 @@ describe("issue 14636", () => {
const pageRows = [limit, total - limit];
const length = pageRows[page];
const stubbedRows = Array.from({ length }, (_, index) => ({
return Array.from({ length }, (_, index) => ({
...row,
id: index + 1,
}));
return stubbedRows;
}
beforeEach(() => {
@@ -356,7 +313,7 @@ describe("scenarios > admin > tools > logs", () => {
* The formatted timestamp may vary depending on the timezone in which the test is run.
* This function makes test assertions timezone-agnostic.
*/
function formatTimestamp(timestamp) {
function formatTimestamp(timestamp: string) {
return dayjs(timestamp).format();
}
});
@@ -381,7 +338,7 @@ describe("admin > tools > erroring questions ", () => {
display: "scalar",
};
function fixQuestion(name) {
function fixQuestion(name: string) {
cy.findByTestId("visualization-root").findByText(name).click();
cy.findByText("Open Editor").click();
@@ -396,7 +353,7 @@ describe("admin > tools > erroring questions ", () => {
});
}
function selectQuestion(name) {
function selectQuestion(name: string) {
cy.findByText(name)
.closest("tr")
.within(() => {
@@ -435,7 +392,7 @@ describe("admin > tools > erroring questions ", () => {
describe("with the existing broken questions", () => {
beforeEach(() => {
H.createNativeQuestion(brokenQuestionDetails, {
H.createNativeQuestion(brokenQuestionDetails as NativeQuestionDetails, {
loadMetadata: true,
});

View File

@@ -1060,8 +1060,8 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
cy.findByText(COUNT_COLUMN_ID).should("exist");
cy.findByText(CREATED_AT_COLUMN_ID).should("exist");
cy.findByText(DASHBOARD_FILTER_TEXT.name).should("exist");
cy.realPress("Escape");
});
H.modal().findByText("Values you can reference").click();
H.modal().within(() => {
cy.findByRole("textbox").type(URL_WITH_PARAMS, {
parseSpecialCharSequences: false,

View File

@@ -133,7 +133,10 @@ describe("scenarios > dashboard cards > replace question", () => {
it("should replace a dashboard card question (metabase#36984)", () => {
visitDashboardAndEdit();
H.findDashCardAction(findHeadingDashcard(), "Replace").should("not.exist");
findHeadingDashcard()
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Replace")
.should("not.exist");
// Ensure can replace with a question
replaceQuestion(findTargetDashcard(), {
@@ -168,7 +171,7 @@ describe("scenarios > dashboard cards > replace question", () => {
it("should undo the question replace action", () => {
visitDashboardAndEdit();
overwriteDashCardTitle(findTargetDashcard(), "Custom name");
overwriteDashCardTitle("Custom name");
connectDashboardFilter(findTargetDashcard(), {
filterName: PARAMETER.UNUSED.name,
columnName: "Discount",
@@ -275,8 +278,11 @@ function assertDashCardTitle(title) {
cy.findByTestId("legend-caption-title").should("have.text", title);
}
function overwriteDashCardTitle(dashcardElement, textTitle) {
H.findDashCardAction(dashcardElement, "Show visualization options").click();
function overwriteDashCardTitle(textTitle) {
findTargetDashcard()
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Show visualization options")
.click();
H.modal().within(() => {
cy.findByLabelText("Title").type(`{selectall}{del}${textTitle}`).blur();
cy.button("Done").click();

View File

@@ -79,7 +79,10 @@ describe("scenarios > dashboard cards > duplicate", () => {
H.visitDashboard("@dashboardId");
cy.findByLabelText("Edit dashboard").click();
H.findDashCardAction(H.getDashboardCard(0), "Duplicate").click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Duplicate")
.click();
H.expectUnstructuredSnowplowEvent(EVENTS.duplicateDashcard);
// check that the new card loads _before_ saving

View File

@@ -430,7 +430,10 @@ describe("dashboard filters auto-wiring", () => {
goToFilterMapping();
H.findDashCardAction(H.getDashboardCard(1), "Replace").click();
H.getDashboardCard(1)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Replace")
.click();
H.modal().findByText("Orders, Count").click();

View File

@@ -1234,7 +1234,10 @@ describe("scenarios > dashboard > parameters", () => {
H.editDashboard();
});
H.findDashCardAction(H.getDashboardCard(0), "Duplicate").click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Duplicate")
.click();
H.getDashboardCard(2).within(() => {
cy.findByDisplayValue("Heading Text").should("exist");
@@ -2315,7 +2318,10 @@ describe("scenarios > dashboard > parameters", () => {
H.selectDashboardFilter(H.getDashboardCard(0), "Count");
H.dashboardParameterSidebar().button("Done").click();
H.findDashCardAction(H.getDashboardCard(0), "Duplicate").click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Duplicate")
.click();
H.getDashboardCard(1).within(() => {
cy.findByTestId("chart-container").should("exist");

View File

@@ -195,7 +195,10 @@ describe("scenarios > dashboard > visualizer > basics", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.editDashboard();
H.findDashCardAction(dashCard(), "Visualize another way").click();
dashCard()
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
H.modal().within(() => {
H.assertDataSourceColumnSelected("Orders", "ID");
@@ -213,14 +216,19 @@ describe("scenarios > dashboard > visualizer > basics", () => {
});
});
H.findDashCardAction(dashCard(), "Visualize another way").should(
"not.exist",
);
H.findDashCardAction(dashCard(), "Show visualization options").should(
"not.exist",
);
dashCard()
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.should("not.exist");
dashCard()
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Show visualization options")
.should("not.exist");
dashCard()
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Edit visualization")
.click();
H.findDashCardAction(dashCard(), "Edit visualization").click();
H.modal().button("Save").should("be.disabled");
});
@@ -388,10 +396,10 @@ describe("scenarios > dashboard > visualizer > basics", () => {
});
H.editDashboard();
H.findDashCardAction(
H.getDashboardCard(0),
"Visualize another way",
).click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
H.modal().within(() => {
H.selectVisualization("bar");
@@ -439,10 +447,10 @@ describe("scenarios > dashboard > visualizer > basics", () => {
H.tooltip().findByText("Original question description").should("exist");
H.editDashboard();
H.findDashCardAction(
H.getDashboardCard(0),
"Visualize another way",
).click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
H.modal().within(() => {
cy.findByDisplayValue("Original Question Title").should("exist");
@@ -825,10 +833,10 @@ describe("scenarios > dashboard > visualizer > basics", () => {
);
H.editDashboard();
H.findDashCardAction(
H.getDashboardCard(0),
"Visualize another way",
).click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
cy.intercept("GET", "/api/card/*/query_metadata").as("queryMetadata");
@@ -868,7 +876,10 @@ describe("scenarios > dashboard > visualizer > basics", () => {
cy.findByText("1").should("exist");
});
H.findDashCardAction(H.getDashboardCard(0), "Edit visualization").click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Edit visualization")
.click();
H.modal().within(() => {
H.dataImporter().findByText(baseQuestion.name).should("exist");
H.dataImporter().findByText(invalidQuestion.name).should("exist");

View File

@@ -139,10 +139,10 @@ describe("issue 61521", () => {
});
H.editDashboard();
H.findDashCardAction(
H.getDashboardCard(0),
"Visualize another way",
).click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
H.modal().within(() => {
H.switchToAddMoreData();

View File

@@ -1,4 +1,5 @@
import {
DOCUMENT_WITH_SUPPORTING_TEXT,
DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS,
DOCUMENT_WITH_TWO_CARDS,
} from "e2e/support/document-initial-data";
@@ -455,4 +456,112 @@ describe("documents supporting text", () => {
H.dragAndDropCardOnAnotherCard("Orders, Count", targetCardTitle);
H.documentContent().findByText("Lorem ipsum").should("not.exist");
});
describe("drag and drop", () => {
beforeEach(() => {
H.createDocument({
name: "DnD Test Document",
document: DOCUMENT_WITH_SUPPORTING_TEXT,
collection_id: null,
alias: "document",
idAlias: "documentId",
});
H.visitDocument("@documentId");
});
const getSupportingText = (contents: string = "Lorem ipsum") => {
const testId = "document-card-supporting-text";
return cy
.findAllByTestId(testId)
.contains(contents)
.closest(`[data-testid="${testId}"]`);
};
const assertHorizontalLayout = (
c1: Cypress.Chainable<JQuery<HTMLElement>>,
c2: Cypress.Chainable<JQuery<HTMLElement>>,
) => {
c1.then(($c1) => {
c2.then(($c2) => {
const rect1 = $c1[0].getBoundingClientRect();
const rect2 = $c2[0].getBoundingClientRect();
expect(rect2.left).to.gte(rect1.right);
expect(rect1.top).to.be.closeTo(rect2.top, 2);
});
});
};
const assertVerticalLayout = (
c1: Cypress.Chainable<JQuery<HTMLElement>>,
c2: Cypress.Chainable<JQuery<HTMLElement>>,
) => {
c1.then(($c1) => {
c2.then(($c2) => {
const rect1 = $c1[0].getBoundingClientRect();
const rect2 = $c2[0].getBoundingClientRect();
expect(rect2.top).to.gte(rect1.bottom);
expect(rect1.left).to.be.closeTo(rect2.left, 2);
});
});
};
it("should reorder when dropping a supporting text block onto a card", () => {
H.getDocumentCard("Orders").should("exist");
H.documentsDragAndDrop({
getSource: () => getSupportingText().find("[data-drag-handle]"),
getTarget: () => H.getDocumentCard("Orders"),
side: "right",
});
assertHorizontalLayout(H.getDocumentCard("Orders"), getSupportingText());
assertVerticalLayout(
H.getDocumentCard("Orders"),
H.getDocumentCard("Orders, Count"),
);
});
it("should reorder when dropping a card onto a supporting text block", () => {
H.getDocumentCard("Orders").should("exist");
H.documentsDragAndDrop({
getSource: () => H.getDocumentCard("Orders"),
getTarget: () => getSupportingText(),
side: "left",
});
assertHorizontalLayout(H.getDocumentCard("Orders"), getSupportingText());
assertVerticalLayout(
H.getDocumentCard("Orders"),
H.getDocumentCard("Orders, Count"),
);
});
it("should insert a card when dropped onto a supporting text block", () => {
H.getDocumentCard("Orders").should("exist");
H.documentsDragAndDrop({
getSource: () => H.getDocumentCard("Orders, Count"),
getTarget: () => getSupportingText(),
side: "left",
});
assertHorizontalLayout(
H.getDocumentCard("Orders, Count"),
getSupportingText(),
);
assertHorizontalLayout(getSupportingText(), H.getDocumentCard("Orders"));
});
it("should do nothing if dragging a supporting text block outside of a group", () => {
H.getDocumentCard("Orders").should("exist");
H.documentsDragAndDrop({
getSource: () => getSupportingText().find("[data-drag-handle]"),
getTarget: () => H.getDocumentCard("Orders, Count"),
});
assertHorizontalLayout(getSupportingText(), H.getDocumentCard("Orders"));
assertVerticalLayout(
getSupportingText(),
H.getDocumentCard("Orders, Count"),
);
});
});
});

View File

@@ -391,6 +391,45 @@ describe("scenarios > embedding > dashboard parameters", () => {
});
});
it("should allow searching dashboard parameters in preview embed modal", () => {
H.visitDashboard("@dashboardId");
H.openStaticEmbeddingModal({
activeTab: "parameters",
previewMode: "preview",
});
H.modal().within(() => {
// Set the Name parameter to enabled so we can test searching
cy.findByText("Name")
.parent()
.within(() => {
cy.findByText("Disabled").click();
});
});
H.popover().findByText("Editable").click();
// Test the preview iframe parameter search functionality
H.getIframeBody().within(() => {
// Open the Name filter dropdown
cy.findByTestId("dashboard-parameters-widget-container")
.findByText("Name")
.click();
// Test searching for names containing specific text
cy.findByPlaceholderText("Search by Name").type("Af");
// Verify that search results are filtered
H.popover().within(() => {
// Should show names containing "Af"
cy.findByText("Afton Lesch").should("be.visible");
// Should not show names that don't match the search
cy.findByText("Lina Heaney").should("not.exist");
});
});
});
it("should render error message when `params` is not an object (metabase#14474)", () => {
cy.get("@dashboardId").then((dashboardId) => {
cy.request("PUT", `/api/dashboard/${dashboardId}`, {

View File

@@ -1590,6 +1590,89 @@ describe("scenarios > embedding > full app", () => {
);
});
describe("navigation through postMessage", () => {
const assertIsLost = () => {
cy.get("@iframeBody")
.find("[role=status]")
.should("contain", "We're a little lost");
};
const assertIsDashboard = () => {
cy.get("@iframeBody")
.find("[data-testid=table-footer]")
.should("contain", "Showing first 2,000 rows");
};
const assertIsQuestion = () => {
cy.get("@iframeBody")
.find("[data-testid=question-row-count]")
.should("contain", "Showing first 2,000 rows");
};
const goTo = (url) => {
H.postMessageToIframe({
iframeSelector: 'iframe[src*="localhost:4000/dashboard"]',
messageData: {
metabase: { type: "location", location: url },
},
});
};
it("should handle invalid questions/dashboards (metabase#65500)", () => {
cy.signInAsAdmin();
H.createDashboardWithTabs({
dashboard: {
name: "Dashboard with tabs",
},
dashcards: [
createMockDashboardCard({
card_id: ORDERS_QUESTION_ID,
size_x: 10,
size_y: 8,
}),
],
}).then((dashboard) => {
H.loadInteractiveIframeEmbedTestPage({
dashboardId: dashboard.id,
iframeSelector: 'iframe[src*="localhost:4000/dashboard"]',
});
cy.get('iframe[src*="localhost:4000/dashboard"]')
.its("0.contentDocument.body")
.should("not.be.empty")
.then(cy.wrap)
.as("iframeBody");
assertIsDashboard();
// invalid dashboard -> valid dashboard
goTo("/dashboard/9999990");
assertIsLost();
goTo(`/dashboard/${dashboard.id}`);
assertIsDashboard();
// invalid question -> valid question
goTo("/question/9999990");
assertIsLost();
goTo(`/question/${ORDERS_QUESTION_ID}`);
assertIsQuestion();
// invalid question -> valid dashboard
goTo("/question/9999990");
assertIsLost();
goTo(`/dashboard/${dashboard.id}`);
assertIsDashboard();
// invalid dashboard -> valid question
goTo("/dashboard/9999990");
assertIsLost();
goTo(`/question/${ORDERS_QUESTION_ID}`);
assertIsQuestion();
});
});
});
it("should send `frame` message with dashboard height when the dashboard is resized (metabase#37437)", () => {
const TAB_1 = { id: 1, name: "Tab 1" };
const TAB_2 = { id: 2, name: "Tab 2" };

View File

@@ -34,6 +34,10 @@ describe("scenarios > embedding > sdk iframe embedding > view and curate content
.click();
cy.log("should show question view");
cy.get("iframe[data-metabase-embed]").should(($iframe) => {
const body = $iframe.contents().find("body");
expect(body).to.exist;
});
H.getSimpleEmbedIframeContent()
.findByTestId("query-visualization-root")
.should("be.visible");

View File

@@ -4604,7 +4604,10 @@ describe("issue 62627", () => {
cy.log("add an inline card filter");
H.showDashboardCardActions();
H.findDashCardAction(H.getDashboardCard(), "Add a filter").click();
H.getDashboardCard()
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Add a filter")
.click();
H.popover().findByText("Text or Category").click();
H.selectDashboardFilter(H.getDashboardCard(), "Category");
H.setDashboardParameterName("Category");

View File

@@ -228,6 +228,59 @@ describe("scenarios > metabot > transforms codegen", () => {
assertEditorContent("python", "pd.DataFrame({'value': [3]})");
assertAcceptRejectUI({ visible: false });
});
it("should create SQL transform with model reference via metabot and run successfully", () => {
// Create a model first
H.getTableId({ name: SOURCE_TABLE, databaseId: WRITABLE_DB_ID }).as(
"tableId",
);
cy.get("@tableId").then((tableId) => {
H.createQuestion({
name: "Test Model",
type: "model",
query: {
"source-table": tableId,
limit: 5,
},
}).as("model");
});
cy.get("@model").then(({ body: model }) => {
visitTransformListPage();
H.openMetabotViaSearchButton(true);
cy.log("Ask metabot for a new transform that references the model");
const modelTagName = `#${model.id}-test-model`;
const queryWithModelRef = `SELECT * FROM {{${modelTagName}}}`;
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"I'll create a transform that queries your model.",
createMockNativeTransformJSON(
null,
WRITABLE_DB_ID,
queryWithModelRef,
),
),
});
H.sendMetabotMessage(
"Create a transform that queries the Test Model",
);
assertSuggestionInSidebar({ newSourcePartial: "SELECT * FROM" });
cy.log("Should be able to visit the new transform");
viewLastSuggestion();
cy.url().should("include", "/admin/transforms/new/native");
assertEditorContent("native", "SELECT * FROM");
cy.log(
"Should be able to run the transform successfully (verifies template tags were parsed)",
);
cy.findByTestId("native-query-editor-container").icon("play").click();
cy.findByTestId("query-visualization-root").should("be.visible");
});
});
});
describe("update existing transform", () => {

View File

@@ -91,10 +91,10 @@ describe("scenarios > metrics > dashboard", () => {
);
H.editDashboard();
H.findDashCardAction(
H.getDashboardCard(0),
"Visualize another way",
).click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
H.modal().within(() => {
H.switchToAddMoreData();
H.selectDataset(PRODUCTS_SCALAR_METRIC.name);

View File

@@ -137,10 +137,10 @@ describe("issue 44171", () => {
H.sidebar().findByText("Metric 44171-A").click();
H.showDashboardCardActions(0);
H.findDashCardAction(
H.getDashboardCard(0),
"Visualize another way",
).click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
H.modal().within(() => {
H.switchToAddMoreData();
H.selectDataset("Metric 44171-B");

View File

@@ -323,10 +323,10 @@ describe("issue 21559", { tags: "@external" }, () => {
it("should respect dashboard card visualization (metabase#21559)", () => {
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
H.findDashCardAction(
H.getDashboardCard(0),
"Visualize another way",
).click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
H.modal().within(() => {
H.switchToAddMoreData();

View File

@@ -1,22 +1,56 @@
/**
* Warning!
* Do not modify SAMPLE_DB data to test any table editing features.
* It is used in multiple tests and any changes will break them.
*/
import dayjs from "dayjs";
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
import {
SAMPLE_DB_ID,
USER_GROUPS,
WRITABLE_DB_ID,
} from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { resetSnowplow } from "e2e/support/helpers/e2e-snowplow-helpers";
import { DataPermissionValue } from "metabase/admin/permissions/types";
const { H } = cy;
const { ALL_USERS_GROUP } = USER_GROUPS;
const { ORDERS_ID, PRODUCTS_ID, ORDERS, PRODUCTS } = SAMPLE_DATABASE;
const { ORDERS_ID, PRODUCTS_ID, PEOPLE_ID, ORDERS, PRODUCTS } = SAMPLE_DATABASE;
const EDITABLE_SOURCE_TABLE_NAME = "many_data_types";
const EDITABLE_SOURCE_TABLE_NAME_REGEX = new RegExp("Many Data Types", "i");
const INLINE_EDIT_TEST_TABLE_NAME = "editing_test";
const DEFAULT_FIELD = "UUID";
describe("scenarios > table-editing", () => {
beforeEach(() => {
resetSnowplow();
H.restore();
H.restore("postgres-writable");
H.resetTestTable({
type: "postgres",
table: EDITABLE_SOURCE_TABLE_NAME,
});
cy.signInAsAdmin();
cy.updatePermissionsGraph({
[ALL_USERS_GROUP]: {
[WRITABLE_DB_ID]: {
"view-data": DataPermissionValue.UNRESTRICTED,
"create-queries": DataPermissionValue.QUERY_BUILDER_AND_NATIVE,
},
},
});
H.resyncDatabase({
dbId: WRITABLE_DB_ID,
tableName: EDITABLE_SOURCE_TABLE_NAME,
});
H.activateToken("bleeding-edge");
setTableEditingEnabledForDB(SAMPLE_DB_ID);
setTableEditingEnabledForDB(WRITABLE_DB_ID);
cy.intercept("GET", "/api/database").as("getDatabases");
cy.intercept("GET", "/api/table/*").as("getTable");
@@ -24,24 +58,28 @@ describe("scenarios > table-editing", () => {
it("should show edit icon on table browser", () => {
openTableBrowser();
getTableEditIcon("People").should("be.visible");
getTableEditIcon(EDITABLE_SOURCE_TABLE_NAME_REGEX).should("be.visible");
});
it("should not show edit icon on table browser if user is not admin", () => {
cy.signInAsNormalUser();
openTableBrowser();
getTableEditIcon("People").should("not.exist");
getTableEditIcon(EDITABLE_SOURCE_TABLE_NAME_REGEX).should("not.exist");
});
it("should allow to open table data edit mode", () => {
openTableBrowser();
openTableEdit("People");
openTableEdit(EDITABLE_SOURCE_TABLE_NAME_REGEX);
H.expectUnstructuredSnowplowEvent({
event: "edit_data_button_clicked",
triggered_from: "table-browser",
target_id: PEOPLE_ID,
H.getTableId({
name: EDITABLE_SOURCE_TABLE_NAME,
}).then((tableId) => {
H.expectUnstructuredSnowplowEvent({
event: "edit_data_button_clicked",
triggered_from: "table-browser",
target_id: tableId,
});
});
cy.wait("@getTable");
@@ -49,7 +87,7 @@ describe("scenarios > table-editing", () => {
cy.findByTestId("edit-table-data-root")
.should("be.visible")
.within(() => {
cy.findByText("People").should("be.visible");
cy.findByText(EDITABLE_SOURCE_TABLE_NAME_REGEX).should("be.visible");
cy.findByTestId("head-crumbs-container")
.findByText("Edit")
.should("be.visible");
@@ -57,16 +95,18 @@ describe("scenarios > table-editing", () => {
cy.findByTestId("table-root").should("be.visible");
});
cy.findByTestId("head-crumbs-container").findByText("People").click();
cy.findByTestId("head-crumbs-container")
.findByText(EDITABLE_SOURCE_TABLE_NAME_REGEX)
.click();
cy.findByTestId("query-builder-root").should("be.visible");
});
describe("db setting is disabled", () => {
it("should not allow to open table data view by default", () => {
setTableEditingEnabledForDB(SAMPLE_DB_ID, false);
setTableEditingEnabledForDB(WRITABLE_DB_ID, false);
openTableBrowser();
getTableEditIcon("People").should("not.exist");
getTableEditIcon(EDITABLE_SOURCE_TABLE_NAME_REGEX).should("not.exist");
});
});
@@ -76,8 +116,13 @@ describe("scenarios > table-editing", () => {
});
it("should not allow to open table data view", () => {
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
cy.findByTestId("edit-table-data-restricted").should("be.visible");
H.getTableId({
databaseId: WRITABLE_DB_ID,
name: EDITABLE_SOURCE_TABLE_NAME,
}).then((tableId) => {
cy.visit(`/browse/databases/${WRITABLE_DB_ID}/tables/${tableId}/edit`);
cy.findByTestId("edit-table-data-restricted").should("be.visible");
});
});
});
@@ -85,26 +130,59 @@ describe("scenarios > table-editing", () => {
beforeEach(() => {
resetSnowplow();
cy.intercept("GET", `/api/table/${ORDERS_ID}/query_metadata`).as(
"getOrdersTable",
H.queryWritableDB(
`CREATE TABLE IF NOT EXISTS ${INLINE_EDIT_TEST_TABLE_NAME} AS SELECT id, uuid, integer, tinyint, string, date, datetime, boolean FROM ${EDITABLE_SOURCE_TABLE_NAME}`,
"postgres",
);
H.resyncDatabase({ dbId: WRITABLE_DB_ID });
cy.intercept("POST", "api/dataset").as("getTableDataQuery");
cy.intercept("POST", "api/ee/action-v2/execute-bulk").as(
"updateTableData",
H.getTableId({
databaseId: WRITABLE_DB_ID,
name: INLINE_EDIT_TEST_TABLE_NAME,
}).then((tableId) => {
/*
Adjusting "string" column type.
By default it is set to "type/Category" due to only 2 unique values in test dataset.
This causes to "string" column cell rendering a dropdown instead of an input field
when editing.
*/
H.getFieldId({
tableId,
name: "string",
}).then((fieldId) => {
cy.request("PUT", `/api/field/${fieldId}`, {
semantic_type: null,
});
});
cy.intercept("GET", `/api/table/${tableId}/query_metadata`).as(
"getDataTable",
);
cy.intercept("POST", "api/dataset").as("getTableDataQuery");
cy.intercept("POST", "api/ee/action-v2/execute-bulk").as(
"updateTableData",
);
cy.visit(`/browse/databases/${WRITABLE_DB_ID}/tables/${tableId}/edit`);
cy.wait("@getDataTable");
cy.log("table data loaded");
});
});
afterEach(() => {
H.queryWritableDB(
`DROP TABLE IF EXISTS ${INLINE_EDIT_TEST_TABLE_NAME}`,
"postgres",
);
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
cy.wait("@getOrdersTable");
});
it("should allow to filter table data", () => {
cy.findByTestId("edit-table-data-root").findByText("Filter").click();
H.popover().within(() => {
cy.findByText("Discount").click();
cy.findByText("Between").click();
cy.findByText("ID").click();
cy.findByText("Is").click();
});
H.menu().findByText("Not empty").click();
@@ -132,18 +210,18 @@ describe("scenarios > table-editing", () => {
it("should allow to sort table data", () => {
cy.findByTestId("table-header").within(() => {
cy.findByText("Quantity").click();
cy.findByText(DEFAULT_FIELD).click();
cy.findAllByTestId("header-sort-indicator").should("have.length", 1);
cy.findByText("Quantity")
cy.findByText(DEFAULT_FIELD)
.closest("[role=button]")
.findByLabelText("chevronup icon")
.should("be.visible");
cy.findByText("Quantity").click();
cy.findByText(DEFAULT_FIELD).click();
cy.findByText("Quantity")
cy.findByText(DEFAULT_FIELD)
.closest("[role=button]")
.findByLabelText("chevrondown icon")
.should("be.visible");
@@ -154,19 +232,19 @@ describe("scenarios > table-editing", () => {
cy.wait("@getTableDataQuery");
cy.findByTestId("table-header").within(() => {
cy.findByText("Quantity")
cy.findByText(DEFAULT_FIELD)
.closest("[role=button]")
.findByLabelText("chevrondown icon")
.should("be.visible");
cy.findByText("Quantity").click();
cy.findByText(DEFAULT_FIELD).click();
cy.findAllByTestId("header-sort-indicator").should("have.length", 0);
});
});
it("should allow to view row details", () => {
openEditRowModal(2);
openEditRowModal(1);
H.modal().within(() => {
cy.get("@rowId").then((rowId) => {
cy.findByTestId("ID-field-input").should("have.text", rowId);
@@ -175,11 +253,11 @@ describe("scenarios > table-editing", () => {
});
it("should allow to edit a row using a modal", () => {
openEditRowModal(2);
openEditRowModal(1);
H.modal().within(() => {
cy.findByTestId("Quantity-field-input")
.type("{backspace}{backspace}{backspace}123")
cy.findByTestId("Integer-field-input")
.type("{selectAll}{backspace}123")
.blur();
cy.findByTestId("update-row-save-button").click();
@@ -187,64 +265,53 @@ describe("scenarios > table-editing", () => {
cy.wait("@updateTableData").then(({ response }) => {
expect(response?.body.outputs[0].op).to.equal("updated");
expect(response?.body.outputs[0].row.QUANTITY).to.equal(123);
expect(response?.body.outputs[0].row.integer).to.equal(123);
});
H.modal().should("not.exist");
H.undoToast().findByText("Successfully updated").should("be.visible");
H.expectUnstructuredSnowplowEvent({
event: "edit_data_record_modified",
event_detail: "update",
target_id: ORDERS_ID,
triggered_from: "modal",
result: "success",
H.getTableId({
databaseId: WRITABLE_DB_ID,
name: INLINE_EDIT_TEST_TABLE_NAME,
}).then((tableId) => {
H.expectUnstructuredSnowplowEvent({
event: "edit_data_record_modified",
event_detail: "update",
target_id: tableId,
triggered_from: "modal",
result: "success",
});
});
});
describe("inline cell editing", () => {
// we use randomized values here to allow multiple runs in one session (since non-changed values are not updated)
const cases = [
{
tableId: PRODUCTS_ID,
dataType: "string",
column: "EAN",
value: Math.floor(Math.random() * 100000)
.toString()
.padStart(13, "0"),
dataType: "integer",
column: "integer",
value: Math.floor(Math.random() * 10000),
},
{
tableId: ORDERS_ID,
dataType: "number",
column: "TAX",
value: Math.floor(Math.random() * 1000),
dataType: "tinyint",
column: "tinyint",
value: Math.floor(Math.random() * 256),
},
{
dataType: "string",
column: "string",
value: "test",
},
// TODO: fix flaky test due to timezone issues (runs locally but fails on CI)
// {
// tableId: PEOPLE_ID,
// dataType: "date",
// column: "BIRTH_DATE",
// value: dayjs(
// new Date(
// Date.now() -
// Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 365),
// ),
// ).format("YYYY-MM-DD"),
// },
];
cases.forEach(({ dataType, column, value, tableId }) => {
cases.forEach(({ dataType, column, value }) => {
it(`should allow to edit a cell with type ${dataType}`, () => {
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${tableId}/edit`);
// Locate the table and the specific cell to edit
cy.findByTestId("table-root")
.findAllByRole("row")
.eq(1) // Select the second row (index 1)
.within(() => {
cy.get(`[data-column-id='${column}']`)
.as("targetCell")
.click({ scrollBehavior: "center" }); // Activate inline editing
cy.get(`[data-column-id='${column}']`).as("targetCell").click(); // Activate inline editing
});
// Edit the cell value
@@ -266,29 +333,59 @@ describe("scenarios > table-editing", () => {
}
});
H.expectUnstructuredSnowplowEvent({
event: "edit_data_record_modified",
event_detail: "update",
target_id: tableId,
triggered_from: "inline",
result: "success",
H.getTableId({
databaseId: WRITABLE_DB_ID,
name: INLINE_EDIT_TEST_TABLE_NAME,
}).then((tableId) => {
H.expectUnstructuredSnowplowEvent({
event: "edit_data_record_modified",
event_detail: "update",
target_id: tableId,
triggered_from: "inline",
result: "success",
});
});
H.undoToast().findByText("Successfully updated").should("be.visible");
});
});
it("should allow to edit a cell with datetime type", () => {
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
// Locate the table and the specific cell to edit
it("should allow to edit a cell with date type", () => {
cy.findByTestId("table-root")
.findAllByRole("row")
.eq(1) // Select the second row (index 1)
.eq(1)
.within(() => {
cy.get("[data-column-id='CREATED_AT']").as("targetCell").click({
cy.get("[data-column-id='date']").as("targetCell").click({
scrollBehavior: false,
}); // Activate inline editing
});
});
const day = Math.floor(Math.random() * 10) + 10; // 10-20
H.popover().within(() => {
cy.findByRole("button", { name: `${day} February 2020` }).click();
});
cy.wait("@updateTableData").then(({ response }) => {
const targetDate = dayjs(new Date(2020, 1, day)).format("YYYY-MM-DD");
const responseDate = dayjs(response?.body.outputs[0].row.date).format(
"YYYY-MM-DD",
);
expect(responseDate).to.equal(targetDate);
});
H.undoToast().findByText("Successfully updated").should("be.visible");
});
it("should allow to edit a cell with datetime type", () => {
cy.findByTestId("table-root")
.findAllByRole("row")
.eq(1)
.within(() => {
cy.get("[data-column-id='datetime']").as("targetCell").click({
scrollBehavior: false,
});
});
const day = Math.floor(Math.random() * 10) + 10; // 10-20
@@ -296,9 +393,11 @@ describe("scenarios > table-editing", () => {
const minute = Math.floor(Math.random() * 60);
H.popover().within(() => {
cy.findByRole("button", { name: `${day} May 2024` }).click();
cy.findByRole("button", { name: `${day} February 2020` }).click();
cy.findAllByRole("spinbutton").eq(0).type(hour.toString());
cy.findAllByRole("spinbutton").eq(1).type(minute.toString());
cy.get('select[data-am-pm="true"]').as("ampmSelect");
cy.get("@ampmSelect").select("AM");
// It's safe to click the last button because we're in the popover
// eslint-disable-next-line no-unsafe-element-filtering
cy.findAllByRole("button").last().click();
@@ -306,11 +405,11 @@ describe("scenarios > table-editing", () => {
cy.wait("@updateTableData").then(({ response, request }) => {
const targetDate = dayjs(
new Date(2024, 4, day, hour, minute, 0),
new Date(2020, 1, day, hour, minute, 0),
).format("YYYY-MM-DDTHH:mm:ss");
const requestDate = request.body.params.CREATED_AT;
const responseDate = response?.body.outputs[0].row.CREATED_AT;
const requestDate = request.body.params.datetime;
const responseDate = response?.body.outputs[0].row.datetime;
// Check if request date matches the response date.
// In theory FE should preserve the initial input date timezone offset (might be based on the CI environment)
@@ -324,24 +423,18 @@ describe("scenarios > table-editing", () => {
});
it("should allow to edit a cell with select type", () => {
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
cy.findByTestId("table-root")
.findAllByRole("row")
.eq(1)
.within(() => {
cy.get("[data-column-id='USER_ID']").as("targetCell").click({
cy.get("[data-column-id='boolean']").as("targetCell").click({
scrollBehavior: false,
});
});
H.popover().within(() => {
const randomIndex = Math.floor(2 + Math.random() * 18);
cy.findAllByRole("option")
.should("have.length", 20)
.eq(randomIndex)
.click();
// 3: true, false, null
cy.findAllByRole("option").should("have.length", 3).eq(0).click();
cy.wait("@updateTableData").then(({ response }) => {
expect(response?.body.outputs[0].op).to.equal("updated");
@@ -352,15 +445,11 @@ describe("scenarios > table-editing", () => {
});
it("should not allow to edit PK cells", () => {
cy.visit(
`/browse/databases/${SAMPLE_DB_ID}/tables/${PRODUCTS_ID}/edit`,
);
cy.findByTestId("table-root")
.findAllByRole("row")
.eq(1)
.within(() => {
cy.get("[data-column-id='ID']")
cy.get("[data-column-id='id']")
.as("targetCell")
.click({
scrollBehavior: false,
@@ -371,24 +460,20 @@ describe("scenarios > table-editing", () => {
});
it("should handle errors", () => {
cy.visit(
`/browse/databases/${SAMPLE_DB_ID}/tables/${PRODUCTS_ID}/edit`,
);
cy.findByTestId("table-root")
.findAllByRole("row")
.eq(1)
.within(() => {
cy.get("[data-column-id='EAN']")
cy.get("[data-column-id='tinyint']")
.as("targetCell")
.click({
scrollBehavior: false,
})
.find("input")
// Should be more than 13 characters to trigger the error
.type("{selectAll}{backspace}aaaaaaaaaaaaaaaaaaaaaaaaa", {
// Entering a big number into tinyint column
.type("{selectAll}{backspace}9999999", {
scrollBehavior: false,
}) // Enter the new value
})
.blur(); // Trigger the save action by blurring the input
});
@@ -502,8 +587,9 @@ describe("scenarios > table-editing", () => {
describe("table editing bugs", () => {
it("WRK-907: should not allow to create new values for FK fields", () => {
setTableEditingEnabledForDB(SAMPLE_DB_ID);
cy.intercept("GET", `/api/table/${ORDERS_ID}/query_metadata`).as(
"getOrdersTable",
"getDataTable",
);
const NON_EXISTING_ID = "999999";
cy.intercept(
@@ -517,7 +603,7 @@ describe("scenarios > table-editing", () => {
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
cy.wait("@getOrdersTable");
cy.wait("@getDataTable");
cy.findByTestId("new-record-button").click();
@@ -543,7 +629,7 @@ describe("scenarios > table-editing", () => {
cy.findByTestId("head-crumbs-container")
.findByText("Sample Database")
.click();
openTableEdit("Products");
openTableEdit(new RegExp("Products", "i"));
cy.wait("@getProductsTable");
@@ -569,13 +655,13 @@ function setTableEditingEnabledForDB(dbId: number, enabled = true) {
});
}
function openTableBrowser(databaseName: string = "Sample Database") {
function openTableBrowser(databaseName: string = "Writable Postgres12") {
cy.visit("/browse/databases");
cy.wait("@getDatabases");
cy.findByTestId("database-browser").findByText(databaseName).click();
}
function getTableEditIcon(tableName: string) {
function getTableEditIcon(tableName: RegExp) {
return cy
.findByTestId("browse-schemas")
.contains(tableName)
@@ -583,30 +669,26 @@ function getTableEditIcon(tableName: string) {
.findByTestId("edit-table-icon");
}
function openTableEdit(tableName: string) {
function openTableEdit(tableName: RegExp) {
getTableEditIcon(tableName).click();
}
function openEditRowModal(rowIndex: number) {
cy.findByTestId("table-root")
.findAllByRole("row")
.should("have.length.gte", 10)
.should("have.length.gte", 2)
.eq(rowIndex)
.within(() => {
cy.findAllByTestId("cell-data")
.first()
.eq(0)
.invoke("text")
.then((text) => {
cy.wrap(text).as("rowId");
});
cy.findAllByTestId("cell-data").first().realHover({
scrollBehavior: false,
});
cy.findAllByTestId("cell-data").first().realHover();
cy.findByTestId("row-edit-icon").click({
scrollBehavior: false,
});
cy.findByTestId("row-edit-icon").click();
});
H.modal().findByText("Edit record").should("be.visible");

View File

@@ -645,10 +645,10 @@ describe("issue 21665", () => {
H.editDashboard();
});
H.findDashCardAction(
H.getDashboardCard(0),
"Visualize another way",
).click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Visualize another way")
.click();
H.modal().within(() => {
H.switchToAddMoreData();
H.selectDataset(Q2.name);

View File

@@ -1276,7 +1276,10 @@ describe("issue 52339", () => {
});
H.editDashboard();
H.findDashCardAction(H.getDashboardCard(0), "Click behavior").click();
H.getDashboardCard(0)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Click behavior")
.click();
H.sidebar().within(() => {
cy.findByText("Go to a custom destination").click();

View File

@@ -36,6 +36,7 @@
[metabase-enterprise.semantic-search.api]
[metabase-enterprise.serialization.api]
[metabase-enterprise.stale.api]
[metabase-enterprise.support-access-grants.api]
[metabase-enterprise.transforms-python.api]
[metabase-enterprise.transforms.api]
[metabase-enterprise.upload-management.api]
@@ -72,6 +73,7 @@
:upload-management (deferred-tru "Upload Management")
:database-routing (deferred-tru "Database Routing")
:cloud-custom-smtp (deferred-tru "Custom SMTP")
:support-users (deferred-tru "Support Users")
:transforms-python (deferred-tru "Transforms Python")})
(defn- premium-handler [handler required-feature]
@@ -128,6 +130,7 @@
"/semantic-search" (premium-handler metabase-enterprise.semantic-search.api/routes :semantic-search)
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
"/stale" (premium-handler metabase-enterprise.stale.api/routes :collection-cleanup)
"/support-access-grant" (premium-handler metabase-enterprise.support-access-grants.api/routes :support-users)
"/transform" (premium-handler metabase-enterprise.transforms.api/routes :transforms)
"/transform-job" (premium-handler metabase-enterprise.transforms.api/transform-job-routes :transforms)
"/transform-tag" (premium-handler metabase-enterprise.transforms.api/transform-tag-routes :transforms)

View File

@@ -22,5 +22,6 @@
[metabase-enterprise.semantic-search.init]
[metabase-enterprise.sso.init]
[metabase-enterprise.stale.init]
[metabase-enterprise.support-access-grants.init]
[metabase-enterprise.transforms-python.init]
[metabase-enterprise.transforms.init]))

View File

@@ -4,8 +4,6 @@
[metabase-enterprise.documents.core :as documents]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
;; TODO (Cam 10/10/25) -- update MetaBot to use Lib + MBQL 5
^{:clj-kondo/ignore [:discouraged-namespace]} [metabase.legacy-mbql.normalize :as mbql.normalize]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
@@ -378,23 +376,23 @@
{:output "invalid document_id"}))
(defn- execute-query
[query-id legacy-query]
(let [legacy-query (mbql.normalize/normalize legacy-query)
[query-id query-input]
(let [normalized-query (lib-be/normalize-query query-input)
field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
database-id (:database legacy-query)
database-id (:database normalized-query)
_ (api/read-check :model/Database database-id)
mp (lib-be/application-database-metadata-provider database-id)
query (lib/query mp legacy-query)
query (lib/query mp normalized-query)
returned-cols (lib/returned-columns query)]
{:type :query
:query-id query-id
:query legacy-query
:query normalized-query
:result-columns (into []
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 field-id-prefix))
returned-cols)}))
(defn get-query-details
"Get the details of a (legacy) query."
"Get the details of a query (supports both MBQL v4 and v5)."
[{:keys [query]}]
(lib-be/with-metadata-provider-cache
{:structured-output (execute-query (u/generate-nano-id) query)}))

View File

@@ -350,13 +350,14 @@
(let [{:keys [table-id query query-id report-id]} data-source
model-id (lib.util/legacy-string-table-id->card-id table-id)
handle-query (fn [query query-id]
(let [database-id (:database query)
(let [normalized-query (lib-be/normalize-query query)
database-id (:database normalized-query)
_ (api/read-check :model/Database database-id)
mp (lib-be/application-database-metadata-provider database-id)]
[(if query-id
(metabot-v3.tools.u/query-field-id-prefix query-id)
metabot-v3.tools.u/any-prefix-pattern)
(-> (lib/query mp query) lib/append-stage)]))]
(-> (lib/query mp normalized-query) lib/append-stage)]))]
(cond
model-id
(if-let [model-query (metabot-v3.tools.u/card-query model-id)]

View File

@@ -106,6 +106,7 @@
"Secret"
"SemanticSearchTokenTracking"
"Session"
"SupportAccessGrantLog"
"TaskHistory"
"TransformJobRun"
"TransformRun"

Some files were not shown because too many files have changed in this diff Show More