Compare commits
214 Commits
add-ldap-d
...
nav-split-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16bbf677a5 | ||
|
|
e1d1d31a1a | ||
|
|
7b91252075 | ||
|
|
8dfb8c5b67 | ||
|
|
e8f85e28e0 | ||
|
|
63cbcc92e3 | ||
|
|
16ad7b75f2 | ||
|
|
eedda27c89 | ||
|
|
1a0ff4340b | ||
|
|
0bdad48e91 | ||
|
|
9b193b0937 | ||
|
|
1139be54dd | ||
|
|
32176e152a | ||
|
|
1d5c08b71d | ||
|
|
bc75a88888 | ||
|
|
cd6bcc23e0 | ||
|
|
381ab6c1a0 | ||
|
|
176d0682e2 | ||
|
|
daa0c256a6 | ||
|
|
2df3f6daa0 | ||
|
|
503f96cb55 | ||
|
|
f2e185369f | ||
|
|
9db952d568 | ||
|
|
9e27bdf31f | ||
|
|
20f3f6cbdc | ||
|
|
2f3fe9904e | ||
|
|
57a08becb3 | ||
|
|
fc5cf5c469 | ||
|
|
c03c02a9a1 | ||
|
|
af8ca49a25 | ||
|
|
cfb8cbd6f9 | ||
|
|
3eff0fac3d | ||
|
|
b6f625ed4b | ||
|
|
bb9482f0fd | ||
|
|
8317274709 | ||
|
|
c796ec01ec | ||
|
|
fa7362a1e4 | ||
|
|
361fc91d80 | ||
|
|
f4ab93af92 | ||
|
|
6c4b0182c1 | ||
|
|
0694a11c90 | ||
|
|
fa20179ff6 | ||
|
|
cdc7f386af | ||
|
|
d994a365f7 | ||
|
|
d7b9a71698 | ||
|
|
12a22c0392 | ||
|
|
6ec07f7518 | ||
|
|
aa9c3797e8 | ||
|
|
8ce3936116 | ||
|
|
5bcd89d33f | ||
|
|
916ee6554c | ||
|
|
401167cc00 | ||
|
|
fff70175e0 | ||
|
|
74216b3098 | ||
|
|
2947b47c23 | ||
|
|
abb710334f | ||
|
|
fed0dbfd58 | ||
|
|
edd44ef851 | ||
|
|
1cd4d62061 | ||
|
|
0509e1a5e2 | ||
|
|
81736f712a | ||
|
|
812e9b2254 | ||
|
|
d0ce86c19e | ||
|
|
72e2e9b234 | ||
|
|
426c9e6406 | ||
|
|
feb7aa645c | ||
|
|
93742a629c | ||
|
|
b485c0f7fa | ||
|
|
76979e6167 | ||
|
|
da7629bba6 | ||
|
|
280c1e7be2 | ||
|
|
3d924036db | ||
|
|
883e944cce | ||
|
|
9d1c919f6b | ||
|
|
b08a2f0e65 | ||
|
|
22b1a393dc | ||
|
|
f58c520891 | ||
|
|
f2364b1d27 | ||
|
|
39bd87ede3 | ||
|
|
56232fc3f8 | ||
|
|
61b6635f13 | ||
|
|
86b1f4bca4 | ||
|
|
ac1f4cdc00 | ||
|
|
0e83852557 | ||
|
|
61d0fa5d0f | ||
|
|
504b8ea9ee | ||
|
|
61ce133268 | ||
|
|
f24251fb4e | ||
|
|
7994ad35d8 | ||
|
|
eb845c2ea6 | ||
|
|
3d8a3901a9 | ||
|
|
16bd231ba7 | ||
|
|
89da5b2bc6 | ||
|
|
91f5798258 | ||
|
|
e49c2e60af | ||
|
|
496db401a1 | ||
|
|
188c8f412f | ||
|
|
62831f5e4d | ||
|
|
d5d6eba4c5 | ||
|
|
a3b46fdd3c | ||
|
|
61c0e2915e | ||
|
|
5701125c34 | ||
|
|
5349fb75d7 | ||
|
|
ecb8fe82b2 | ||
|
|
40ebe0bb9f | ||
|
|
0700179dad | ||
|
|
e96a5d884d | ||
|
|
587b659ee7 | ||
|
|
5073c20eb1 | ||
|
|
9d6d4c656e | ||
|
|
4b6744c1f4 | ||
|
|
2f02a29249 | ||
|
|
4c5146d536 | ||
|
|
3b9a1df5ac | ||
|
|
7e9ffc83ce | ||
|
|
e28ae43d1d | ||
|
|
d37f1a9dd3 | ||
|
|
1a32584bb8 | ||
|
|
35bf6ca092 | ||
|
|
d3ce9de3cc | ||
|
|
249390c4b6 | ||
|
|
efbd247394 | ||
|
|
bfc9d9aa91 | ||
|
|
8658d420d2 | ||
|
|
1a4afe2350 | ||
|
|
d53a909a92 | ||
|
|
9daae78940 | ||
|
|
c881cfaa4e | ||
|
|
b4f8c8e8aa | ||
|
|
b826490105 | ||
|
|
499bc1160b | ||
|
|
64e7f5cfc8 | ||
|
|
0cf4271e33 | ||
|
|
69b65837bb | ||
|
|
6636c786e2 | ||
|
|
63b1899372 | ||
|
|
0a441968d1 | ||
|
|
b4513dcdca | ||
|
|
1f1643d5b8 | ||
|
|
95368200b1 | ||
|
|
2602fec3cd | ||
|
|
b9d8328b19 | ||
|
|
7d94211ecd | ||
|
|
7c771f3c33 | ||
|
|
3c3979838a | ||
|
|
49a7152e97 | ||
|
|
a367aac5d8 | ||
|
|
f02360567a | ||
|
|
6318ae1d86 | ||
|
|
f3d1360240 | ||
|
|
efe0882aca | ||
|
|
f1a48e2b5c | ||
|
|
9b59d17d10 | ||
|
|
b084ade539 | ||
|
|
2980fd7f29 | ||
|
|
6faa93e40c | ||
|
|
d3db1a1c53 | ||
|
|
82d075c73c | ||
|
|
9f00ac4fc0 | ||
|
|
4b5f24e0a9 | ||
|
|
46541523bc | ||
|
|
1b0ff2917f | ||
|
|
4a3d32ae0a | ||
|
|
48d493c293 | ||
|
|
01f2c4b624 | ||
|
|
86a761845a | ||
|
|
c19c1bfdeb | ||
|
|
47644f8e40 | ||
|
|
980c6b0018 | ||
|
|
e677b6b267 | ||
|
|
548573f22d | ||
|
|
00232ddc3a | ||
|
|
f6caace66a | ||
|
|
ba327647bc | ||
|
|
dfbff65b08 | ||
|
|
f787cc5b89 | ||
|
|
1b57292d09 | ||
|
|
6fdc4439da | ||
|
|
ad51ac8b78 | ||
|
|
94aea9ae24 | ||
|
|
e79f40e3d3 | ||
|
|
9ce408d114 | ||
|
|
6f423c147d | ||
|
|
7b271d25b6 | ||
|
|
75398ad620 | ||
|
|
34c44f0f7f | ||
|
|
968b105665 | ||
|
|
aecbc3c3cb | ||
|
|
5c18aea2c7 | ||
|
|
18a6ad127c | ||
|
|
afd98b94a5 | ||
|
|
9a5524063b | ||
|
|
d46d7a47e4 | ||
|
|
8d67bf7e3d | ||
|
|
4084c9254b | ||
|
|
5f8f065921 | ||
|
|
7ff180c80a | ||
|
|
6964b63d2b | ||
|
|
98486ccd56 | ||
|
|
7e9e46602f | ||
|
|
7ea204ed6e | ||
|
|
7e36117dbc | ||
|
|
72ea707064 | ||
|
|
781e650301 | ||
|
|
53f48ed3be | ||
|
|
a0845f5373 | ||
|
|
0fd3651033 | ||
|
|
a632ed1799 | ||
|
|
be3443b07d | ||
|
|
02f5855a35 | ||
|
|
30346d6a64 | ||
|
|
c74356b123 | ||
|
|
9344c2bd56 | ||
|
|
a6ce02d1c6 |
@@ -74,12 +74,17 @@
|
||||
:metabase/prefer-with-dynamic-fn-redefs {:level :warning}
|
||||
:metabase/t2-query-namespace {:level :warning}
|
||||
:metabase/table-or-field-query {:level :warning}
|
||||
;; App-DB query hygiene: a value reaching a value slot unmarked, and a query written as
|
||||
;; `:column value` pairs rather than a map. Off by default and enabled per namespace under
|
||||
;; `:config-in-ns`; flip it on globally once every `db.clj` has been converted.
|
||||
:metabase/unsafe-app-db-query {:level :off}
|
||||
:metabase/toucan-model-ns {:level :warning}
|
||||
:metabase/require-shape-checker {:level :warning}
|
||||
:metabase/tests-must-live-in-known-modules {:level :warning}
|
||||
:metabase/validate-defendpoint-route-uses-kebab-case {:level :warning}
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case {:level :warning}
|
||||
:metabase/validate-logging {:level :warning}
|
||||
:metabase/validate-mb-app-db-migrations-test {:level :warning}
|
||||
:metabase/validate-mb-driver-tests {:level :warning}
|
||||
:metabase/validate-mb-once {:level :warning}
|
||||
|
||||
@@ -315,6 +320,12 @@
|
||||
{metabase-enterprise.entity-retrieval.core/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
|
||||
metabase.entity-retrieval.core/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
|
||||
metabase.entity-retrieval.mirror/search-unfiltered {:message "Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
|
||||
metabase-enterprise.transform-testing.expectations.equals/->EqualsRows {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.equals/map->EqualsRows {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.equals/->EqualsSql {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.equals/map->EqualsSql {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.empty/->Empty {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
metabase-enterprise.transform-testing.expectations.empty/map->Empty {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
|
||||
clojure.core/print {:message "Use metabase.util.log instead of clojure.core/print"}
|
||||
clojure.core/println {:message "Use metabase.util.log instead of clojure.core/println"}
|
||||
clojure.core/printf {:message "Use metabase.util.log instead of clojure.core/printf"}
|
||||
@@ -591,6 +602,7 @@
|
||||
clojure.core/defprotocol hooks.clojure.core.def/lint-def
|
||||
clojure.core/defrecord hooks.clojure.core.def/lint-def
|
||||
clojure.core/deftype hooks.clojure.core.def/lint-def
|
||||
clojure.core/ex-info hooks.metabase.agent-message/lint-ex-info
|
||||
clojure.core/ns hooks.clojure.core.ns/lint-ns
|
||||
clojure.core/require hooks.clojure.core.require/lint-require
|
||||
clojure.core/requiring-resolve hooks.clojure.core.require/lint-requiring-resolve
|
||||
@@ -606,14 +618,13 @@
|
||||
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
|
||||
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
|
||||
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
|
||||
metabase.api.macros/defendpoint hooks.metabase.api.macros/defendpoint
|
||||
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations
|
||||
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations!
|
||||
metabase.channel.render.js.pool/with-static-viz-context hooks.common/with-one-top-level-binding
|
||||
metabase.channel.template.handlebars-helper/defhelper hooks.metabase.channel.template.handlebars-helper/defhelper
|
||||
metabase.channel.template.handlebars-test/with-temp-template! hooks.common/with-vec-first-binding
|
||||
@@ -686,6 +697,15 @@
|
||||
metabase.lib.test-util.macros/mbql-query hooks.metabase.test.data/mbql-query
|
||||
metabase.lib.test-util.macros/mbql-5-query hooks.metabase.test.data/mbql-query
|
||||
metabase.lib.test-util.macros/with-testing-against-standard-queries hooks.metabase.lib.test-util.macros/with-testing-against-standard-queries
|
||||
metabase.mcp.transport/jsonrpc-error hooks.metabase.agent-message/lint-jsonrpc-error
|
||||
metabase.mcp.v2.common/ellipsize hooks.metabase.agent-message/lint-ellipsize
|
||||
metabase.mcp.v2.common/error-content hooks.metabase.agent-message/lint-teaching-exit
|
||||
metabase.mcp.v2.common/list-content hooks.metabase.agent-message/lint-list-content
|
||||
metabase.mcp.v2.common/success-content hooks.metabase.agent-message/lint-success-content
|
||||
metabase.mcp.v2.common/throw-teaching-error hooks.metabase.agent-message/lint-teaching-exit
|
||||
metabase.mcp.v2.dashboard-ops/op-error! hooks.metabase.agent-message/lint-op-error
|
||||
metabase.mcp.v2.message/msg hooks.metabase.agent-message/lint-msg
|
||||
metabase.mcp.v2.tools.browse/paged-list-content hooks.metabase.agent-message/lint-list-content
|
||||
metabase.mcp.v2.tools.parameters-test/with-fixtures hooks.common/with-one-binding
|
||||
metabase.metrics.api-dimension-test/with-seeded-metric hooks.common/with-one-binding
|
||||
metabase.model-persistence.api-test/with-persistence-setup! hooks.common/with-one-top-level-binding
|
||||
@@ -834,7 +854,13 @@
|
||||
metabase.users.models.user-test/with-groups! macros.metabase.users.models.user-test/with-groups!
|
||||
metabase.util.namespaces/import-fn macros.metabase.util.namespaces/import-fn
|
||||
metabase.util.namespaces/import-macro macros.metabase.util.namespaces/import-macro
|
||||
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world}}
|
||||
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world
|
||||
potemkin/import-def macros.potemkin.namespaces/import-def
|
||||
potemkin/import-fn macros.potemkin.namespaces/import-fn
|
||||
potemkin/import-macro macros.potemkin.namespaces/import-macro
|
||||
potemkin.namespaces/import-def macros.potemkin.namespaces/import-def
|
||||
potemkin.namespaces/import-fn macros.potemkin.namespaces/import-fn
|
||||
potemkin.namespaces/import-macro macros.potemkin.namespaces/import-macro}}
|
||||
|
||||
:config-in-comment
|
||||
{:linters {:unresolved-symbol {:level :off}}}
|
||||
@@ -898,7 +924,19 @@
|
||||
;; run Toucan 2 queries anywhere.
|
||||
;;
|
||||
{:pattern "^metabase\\.app-db\\..*"
|
||||
:name app-db-namespaces}]
|
||||
:name app-db-namespaces}
|
||||
;;
|
||||
;; MCP source namespaces, OSS and enterprise, whose prose reaches an LLM agent, excluding test namespaces.
|
||||
;;
|
||||
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)metabase(?:-enterprise)?\\.mcp\\..*"
|
||||
:name agent-message-namespaces}
|
||||
;;
|
||||
;; MCP source namespaces whose caller-facing errors are MCP exits: `metabase.mcp.transport` and `metabase.mcp.v2.*`,
|
||||
;; and their enterprise counterparts. Other MCP namespaces' 4xx `ex-info`s are admin API and middleware errors, not
|
||||
;; MCP exits.
|
||||
;;
|
||||
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)metabase(?:-enterprise)?\\.mcp\\.(?:transport$|v2\\..*)"
|
||||
:name agent-exit-namespaces}]
|
||||
|
||||
:config-in-ns
|
||||
{metabase.app-db.custom-migrations
|
||||
@@ -924,12 +962,25 @@
|
||||
app-db-namespaces
|
||||
{:linters {:metabase/t2-query-namespace {:level :off}}}
|
||||
|
||||
agent-message-namespaces
|
||||
{:linters {:metabase/agent-message-lines {:level :warning}}}
|
||||
|
||||
agent-exit-namespaces
|
||||
{:linters {:metabase/agent-message-exit {:level :warning}}}
|
||||
|
||||
;; Namespaces whose values are marked. Adding one here is what keeps it that way; see
|
||||
;; `metabase.app-db.value-guard`.
|
||||
metabase.content-translation.db
|
||||
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
|
||||
;; .cljc tests run in both Clojure and ClojureScript — `with-dynamic-fn-redefs` is JVM-only,
|
||||
;; so leave these alone.
|
||||
metabase.lib.aggregation-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
metabase.util.time-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
metabase.lib.metadata.calculation-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
|
||||
;; classloader sits below `util` in the module graph, so it can't use `metabase.util.log` without creating
|
||||
;; a cycle; it gets to use tools.logging directly.
|
||||
|
||||
@@ -116,7 +116,10 @@
|
||||
util
|
||||
warehouse-schema-overlay
|
||||
warehouses}
|
||||
:model-exports #{:model/Action}
|
||||
:model-exports #{:model/Action
|
||||
:model/HTTPAction
|
||||
:model/ImplicitAction
|
||||
:model/QueryAction}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
@@ -134,6 +137,7 @@
|
||||
:uses #{actions
|
||||
analytics
|
||||
api
|
||||
api-scope
|
||||
api.eid-translation
|
||||
collections
|
||||
lib
|
||||
@@ -262,6 +266,7 @@
|
||||
events
|
||||
lib.schema
|
||||
models
|
||||
request
|
||||
settings
|
||||
util
|
||||
util.config}
|
||||
@@ -313,7 +318,9 @@
|
||||
api-scope
|
||||
{:team "Metabot"
|
||||
:api #{metabase.api-scope.core
|
||||
metabase.api-scope.init}}
|
||||
metabase.api-scope.data-app
|
||||
metabase.api-scope.init}
|
||||
:uses #{util}}
|
||||
|
||||
api.eid-translation
|
||||
{:team "UX West"
|
||||
@@ -345,11 +352,12 @@
|
||||
|
||||
app-db
|
||||
{:team "UX West"
|
||||
:api #{metabase.app-db.cluster-lock ; TODO FIXME
|
||||
:api #{metabase.app-db.cluster-lock ; TODO (Chris 2026-09-16) -- callers should go through app-db.core
|
||||
metabase.app-db.core
|
||||
metabase.app-db.init
|
||||
metabase.app-db.setting
|
||||
metabase.app-db.setup
|
||||
metabase.app-db.sql-errors
|
||||
metabase.app-db.transient-error}
|
||||
:uses #{auth-provider
|
||||
connection-pool
|
||||
@@ -437,8 +445,15 @@
|
||||
:api #{metabase.batch-processing.core
|
||||
metabase.batch-processing.init}
|
||||
:uses #{app-db
|
||||
parameters
|
||||
query-processor.cache-backend
|
||||
settings
|
||||
util}}
|
||||
util}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
:model/Document
|
||||
:model/Table}}
|
||||
|
||||
bookmarks
|
||||
{:team "UX West"
|
||||
@@ -505,6 +520,7 @@
|
||||
metabase.channel.init
|
||||
metabase.channel.models.channel
|
||||
metabase.channel.render.core
|
||||
metabase.channel.schema
|
||||
metabase.channel.settings
|
||||
metabase.channel.slack
|
||||
metabase.channel.template.core
|
||||
@@ -535,6 +551,7 @@
|
||||
query-processor.pivot
|
||||
request
|
||||
settings
|
||||
startup
|
||||
system
|
||||
task
|
||||
tiles
|
||||
@@ -627,6 +644,7 @@
|
||||
:ns-prefix "metabase.collections-rest"
|
||||
:api #{metabase.collections-rest.api}
|
||||
:uses #{api
|
||||
api-scope
|
||||
api.eid-translation
|
||||
collections
|
||||
documents
|
||||
@@ -672,11 +690,11 @@
|
||||
|
||||
content-verification
|
||||
{:team "UX West"
|
||||
:api #{metabase.content-verification.core
|
||||
metabase.content-verification.init}
|
||||
:api #{metabase.content-verification.core metabase.content-verification.init metabase.content-verification.schema}
|
||||
:uses #{app-db
|
||||
lib.schema
|
||||
models
|
||||
users
|
||||
util}
|
||||
:model-exports #{:model/ModerationReview}
|
||||
:model-imports #{:model/Card :model/Dashboard :model/User}}
|
||||
@@ -734,14 +752,17 @@
|
||||
metabase.dashboards.schema
|
||||
metabase.dashboards.settings
|
||||
metabase.dashboards.write}
|
||||
:uses #{analytics
|
||||
:uses #{actions
|
||||
analytics
|
||||
api
|
||||
app-db
|
||||
audit-app
|
||||
collections
|
||||
content-verification
|
||||
embedding
|
||||
events
|
||||
lib
|
||||
lib.be
|
||||
lib.schema
|
||||
models
|
||||
parameters
|
||||
@@ -779,6 +800,7 @@
|
||||
:uses #{actions
|
||||
analytics
|
||||
api
|
||||
api-scope
|
||||
api.eid-translation
|
||||
app-db
|
||||
channel
|
||||
@@ -786,6 +808,7 @@
|
||||
dashboards
|
||||
embedding
|
||||
events
|
||||
lib
|
||||
lib.be
|
||||
lib.schema
|
||||
models
|
||||
@@ -887,7 +910,6 @@
|
||||
metabase.driver.settings
|
||||
metabase.driver.sql
|
||||
metabase.driver.sql-jdbc
|
||||
metabase.driver.sql-jdbc.connection
|
||||
metabase.driver.sql-jdbc.execute
|
||||
metabase.driver.sql-jdbc.execute.diagnostic
|
||||
metabase.driver.sql-jdbc.sync
|
||||
@@ -913,6 +935,7 @@
|
||||
query-processor.driver-api
|
||||
settings
|
||||
sql-tools
|
||||
startup
|
||||
system
|
||||
tracing
|
||||
util
|
||||
@@ -940,28 +963,6 @@
|
||||
:model-imports #{:model/Card :model/Dashboard}
|
||||
:model-exports #{:model/EmbeddingTheme}}
|
||||
|
||||
embedding-hub
|
||||
{:team "Embedding"
|
||||
:api #{metabase.embedding-hub.api}
|
||||
:uses #{api
|
||||
appearance
|
||||
audit-app
|
||||
embedding
|
||||
metabot
|
||||
permissions
|
||||
premium-features
|
||||
util.config
|
||||
warehouse-schema-overlay}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/ConnectionImpersonation
|
||||
:model/Dashboard
|
||||
:model/Database
|
||||
:model/DatabaseRouter
|
||||
:model/EmbeddingTheme
|
||||
:model/Sandbox
|
||||
:model/Table
|
||||
:model/Tenant}}
|
||||
|
||||
embedding.rest
|
||||
{:team "Embedding"
|
||||
@@ -1004,7 +1005,7 @@
|
||||
|
||||
explorations
|
||||
{:team "UX West"
|
||||
:api #{metabase.explorations.core metabase.explorations.init}
|
||||
:api #{metabase.explorations.api metabase.explorations.core metabase.explorations.init metabase.explorations.schema}
|
||||
:uses #{analytics
|
||||
analytics.interface
|
||||
api
|
||||
@@ -1084,11 +1085,12 @@
|
||||
|
||||
glossary
|
||||
{:team "Gadget"
|
||||
:api #{metabase.glossary.api}
|
||||
:api #{metabase.glossary.api metabase.glossary.core}
|
||||
:uses #{api
|
||||
events
|
||||
lib.schema
|
||||
models
|
||||
remote-sync
|
||||
util}
|
||||
:model-exports #{:model/Glossary}
|
||||
:model-imports #{:model/User}}
|
||||
@@ -1108,6 +1110,7 @@
|
||||
indexed-entities
|
||||
{:team "UX West"
|
||||
:api #{metabase.indexed-entities.api
|
||||
metabase.indexed-entities.core
|
||||
metabase.indexed-entities.init}
|
||||
:uses #{analytics
|
||||
api
|
||||
@@ -1126,8 +1129,8 @@
|
||||
|
||||
interestingness
|
||||
{:team "UX West"
|
||||
:api #{metabase.interestingness.core}
|
||||
:uses #{util}}
|
||||
:api #{metabase.interestingness.chart.types metabase.interestingness.core}
|
||||
:uses #{lib.be util}}
|
||||
|
||||
lib
|
||||
{:team "Querying Platform"
|
||||
@@ -1148,6 +1151,7 @@
|
||||
metabase.lib.normalize
|
||||
metabase.lib.options
|
||||
metabase.lib.pivot
|
||||
metabase.lib.ref
|
||||
metabase.lib.types.isa
|
||||
metabase.lib.util
|
||||
metabase.lib.walk}
|
||||
@@ -1312,6 +1316,7 @@
|
||||
:uses #{activity-feed
|
||||
ai-tracing
|
||||
api
|
||||
api-scope
|
||||
api.eid-translation
|
||||
app-db
|
||||
bookmarks
|
||||
@@ -1320,6 +1325,7 @@
|
||||
comments
|
||||
dashboards
|
||||
documents
|
||||
glossary
|
||||
lib
|
||||
lib.be
|
||||
lib.metadata
|
||||
@@ -1348,6 +1354,7 @@
|
||||
util
|
||||
util.config
|
||||
warehouse-schema-overlay
|
||||
warehouses
|
||||
warehouses.schema}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
@@ -1380,6 +1387,7 @@
|
||||
mcp
|
||||
models
|
||||
request
|
||||
server
|
||||
settings
|
||||
system
|
||||
task
|
||||
@@ -1394,14 +1402,17 @@
|
||||
events
|
||||
lib
|
||||
lib.be
|
||||
lib.metric
|
||||
lib.schema
|
||||
metrics
|
||||
models
|
||||
permissions
|
||||
remote-sync
|
||||
search
|
||||
users
|
||||
util
|
||||
warehouse-schema-overlay}
|
||||
warehouse-schema-overlay
|
||||
warehouses.schema}
|
||||
:model-exports #{:model/Measure}
|
||||
:model-imports #{:model/Collection :model/Table :model/User}}
|
||||
|
||||
@@ -1469,17 +1480,16 @@
|
||||
task
|
||||
timeline
|
||||
transforms
|
||||
transforms.base
|
||||
util
|
||||
util.config
|
||||
warehouse-schema-overlay
|
||||
warehouses
|
||||
warehouses.schema}
|
||||
warehouses}
|
||||
:model-exports #{:model/AiUsageLog
|
||||
:model/Metabot
|
||||
:model/MetabotConversation
|
||||
:model/MetabotFeedback
|
||||
:model/MetabotMessage}
|
||||
:model/MetabotMessage
|
||||
:model/MetabotPrompt}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
@@ -1560,6 +1570,7 @@
|
||||
settings
|
||||
setup
|
||||
sql-tools
|
||||
startup
|
||||
sync
|
||||
util
|
||||
util.config
|
||||
@@ -1594,10 +1605,7 @@
|
||||
system
|
||||
upload
|
||||
util}
|
||||
:model-imports #{:model/AuthIdentity
|
||||
:model/Card
|
||||
:model/Database
|
||||
:model/MetabotMessage}}
|
||||
:model-imports #{:model/AuthIdentity :model/Card :model/MetabotMessage}}
|
||||
|
||||
metrics
|
||||
{:team "UX West"
|
||||
@@ -1672,20 +1680,53 @@
|
||||
lib.legacy-mbql
|
||||
lib.metadata
|
||||
lib.schema
|
||||
parameters
|
||||
util
|
||||
util.classloader
|
||||
warehouse-schema-overlay}
|
||||
:model-imports #{:model/Card
|
||||
:model-imports #{:model/Action
|
||||
:model/Card
|
||||
:model/Channel
|
||||
:model/ChannelTemplate
|
||||
:model/Collection
|
||||
:model/CustomVizPlugin
|
||||
:model/Dashboard
|
||||
:model/DashboardCard
|
||||
:model/DashboardCardSeries
|
||||
:model/DashboardTab
|
||||
:model/Database
|
||||
:model/Dimension
|
||||
:model/Document
|
||||
:model/EmbeddingTheme
|
||||
:model/Exploration
|
||||
:model/Field
|
||||
:model/FieldUserSettings
|
||||
:model/FieldValues
|
||||
:model/Glossary
|
||||
:model/HTTPAction
|
||||
:model/ImplicitAction
|
||||
:model/Measure
|
||||
:model/Metabot
|
||||
:model/MetabotPrompt
|
||||
:model/NativeQuerySnippet
|
||||
:model/Notification
|
||||
:model/NotificationCard
|
||||
:model/NotificationHandler
|
||||
:model/NotificationRecipient
|
||||
:model/NotificationSubscription
|
||||
:model/OsiAiContext
|
||||
:model/PythonLibrary
|
||||
:model/QueryAction
|
||||
:model/Segment
|
||||
:model/Table
|
||||
:model/TableIndex
|
||||
:model/Timeline
|
||||
:model/TimelineEvent
|
||||
:model/Transform
|
||||
:model/TransformJob
|
||||
:model/TransformJobTransformTag
|
||||
:model/TransformTag
|
||||
:model/TransformTransformTag
|
||||
:model/User}}
|
||||
|
||||
mq
|
||||
@@ -1701,9 +1742,9 @@
|
||||
|
||||
native-query-snippets
|
||||
{:team "Graphy"
|
||||
:api #{metabase.native-query-snippets.api
|
||||
metabase.native-query-snippets.core}
|
||||
:api #{metabase.native-query-snippets.api metabase.native-query-snippets.core metabase.native-query-snippets.schema}
|
||||
:uses #{api
|
||||
api-scope
|
||||
collections
|
||||
events
|
||||
lib
|
||||
@@ -1712,6 +1753,7 @@
|
||||
permissions
|
||||
premium-features
|
||||
remote-sync
|
||||
users
|
||||
util}
|
||||
:model-exports #{:model/NativeQuerySnippet}
|
||||
:model-imports #{:model/Collection :model/User}}
|
||||
@@ -1754,8 +1796,10 @@
|
||||
util
|
||||
util.config}
|
||||
:model-exports #{:model/Notification
|
||||
:model/NotificationCard
|
||||
:model/NotificationHandler
|
||||
:model/NotificationRecipient}
|
||||
:model/NotificationRecipient
|
||||
:model/NotificationSubscription}
|
||||
:model-imports #{:model/Card
|
||||
:model/Channel
|
||||
:model/ChannelTemplate
|
||||
@@ -1796,6 +1840,7 @@
|
||||
metabase.parameters.shared}
|
||||
:uses #{api
|
||||
app-db
|
||||
dashboards
|
||||
lib
|
||||
lib.be
|
||||
lib.metadata
|
||||
@@ -1817,6 +1862,7 @@
|
||||
:api #{metabase.permissions.core
|
||||
metabase.permissions.init
|
||||
metabase.permissions.metric
|
||||
metabase.permissions.path
|
||||
metabase.permissions.schema}
|
||||
:uses #{api
|
||||
app-db
|
||||
@@ -1834,6 +1880,8 @@
|
||||
util
|
||||
util.config
|
||||
warehouse-schema-overlay
|
||||
warehouses
|
||||
warehouses.schema
|
||||
enterprise/permissions}
|
||||
:model-exports #{:model/ApplicationPermissionsRevision
|
||||
:model/CollectionPermissionGraphRevision
|
||||
@@ -1881,6 +1929,7 @@
|
||||
{:team "UX West"
|
||||
:api #{metabase.premium-features.api
|
||||
metabase.premium-features.core
|
||||
metabase.premium-features.defenterprise
|
||||
metabase.premium-features.init}
|
||||
:uses #{analytics.interface
|
||||
analytics.internal-stats
|
||||
@@ -2070,8 +2119,10 @@
|
||||
:ns-prefix "metabase.queries-rest"
|
||||
:api #{metabase.queries-rest.api}
|
||||
:uses #{api
|
||||
api-scope
|
||||
api.eid-translation
|
||||
collections
|
||||
embedding
|
||||
events
|
||||
lib
|
||||
lib.be
|
||||
@@ -2154,6 +2205,7 @@
|
||||
:uses #{analytics
|
||||
analytics.interface
|
||||
api
|
||||
api-scope
|
||||
app-db
|
||||
appearance
|
||||
audit-app
|
||||
@@ -2264,6 +2316,7 @@
|
||||
lib.schema
|
||||
permissions
|
||||
premium-features
|
||||
server
|
||||
session
|
||||
settings
|
||||
users
|
||||
@@ -2275,18 +2328,23 @@
|
||||
{:team "UX West"
|
||||
:api #{metabase.revisions.api
|
||||
metabase.revisions.core
|
||||
metabase.revisions.init}
|
||||
metabase.revisions.init
|
||||
metabase.revisions.models.revision}
|
||||
:uses #{api
|
||||
collections
|
||||
dashboards
|
||||
events
|
||||
explorations
|
||||
lib
|
||||
lib.be
|
||||
lib.schema
|
||||
measures
|
||||
models
|
||||
parameters
|
||||
queries
|
||||
query-permissions
|
||||
segments
|
||||
transforms
|
||||
util
|
||||
util.config}
|
||||
:model-exports #{:model/Revision}
|
||||
@@ -2301,7 +2359,8 @@
|
||||
:model/Measure
|
||||
:model/ParameterCard
|
||||
:model/Segment
|
||||
:model/Transform}}
|
||||
:model/Transform
|
||||
:model/TransformTest}}
|
||||
|
||||
run-tracking
|
||||
{:team "Gadget"
|
||||
@@ -2343,6 +2402,7 @@
|
||||
:uses #{analytics
|
||||
analytics.interface
|
||||
api
|
||||
api-scope
|
||||
app-db
|
||||
appearance
|
||||
audit-app
|
||||
@@ -2404,8 +2464,10 @@
|
||||
permissions
|
||||
remote-sync
|
||||
search
|
||||
users
|
||||
util
|
||||
warehouse-schema-overlay
|
||||
warehouses.schema
|
||||
xrays}
|
||||
:model-exports #{:model/Segment}
|
||||
:model-imports #{:model/Collection :model/Table :model/User}}
|
||||
@@ -2422,6 +2484,7 @@
|
||||
analytics.interface
|
||||
api
|
||||
api-keys
|
||||
api-scope
|
||||
app-db
|
||||
appearance
|
||||
core.initialization-status
|
||||
@@ -2453,8 +2516,10 @@
|
||||
:api #{metabase.session.api
|
||||
metabase.session.core
|
||||
metabase.session.init
|
||||
metabase.session.schema
|
||||
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
|
||||
:uses #{api
|
||||
api-scope
|
||||
app-db
|
||||
channel
|
||||
events
|
||||
@@ -2577,6 +2642,7 @@
|
||||
server
|
||||
settings
|
||||
sso.auth-identity
|
||||
startup
|
||||
system
|
||||
util
|
||||
util.config}
|
||||
@@ -2604,7 +2670,8 @@
|
||||
|
||||
staleness
|
||||
{:team "UX West"
|
||||
:api #{metabase.staleness.core}}
|
||||
:api #{metabase.staleness.core}
|
||||
:uses #{util}}
|
||||
|
||||
startup
|
||||
{:team "DevEx"
|
||||
@@ -2625,7 +2692,7 @@
|
||||
metabase.sync.field-values
|
||||
metabase.sync.init
|
||||
metabase.sync.schedules
|
||||
metabase.sync.task.sync-databases
|
||||
metabase.sync.task.sync-databases-trigger
|
||||
metabase.sync.util}
|
||||
:uses #{analytics.interface
|
||||
api
|
||||
@@ -2751,6 +2818,7 @@
|
||||
premium-features
|
||||
query-processor
|
||||
settings
|
||||
startup
|
||||
util}
|
||||
:model-imports #{:model/Card :model/Dashboard :model/DashboardCard}}
|
||||
|
||||
@@ -2760,12 +2828,13 @@
|
||||
metabase.timeline.core}
|
||||
:uses #{analytics
|
||||
api
|
||||
api-scope
|
||||
collections
|
||||
events
|
||||
lib.schema
|
||||
models
|
||||
util}
|
||||
:model-exports #{:model/Timeline}
|
||||
:model-exports #{:model/Timeline :model/TimelineEvent}
|
||||
:model-imports #{:model/Collection :model/User}}
|
||||
|
||||
tracing
|
||||
@@ -2824,7 +2893,8 @@
|
||||
:model/TransformJobRun
|
||||
:model/TransformJobTransformTag
|
||||
:model/TransformRun
|
||||
:model/TransformTag}
|
||||
:model/TransformTag
|
||||
:model/TransformTransformTag}
|
||||
:model-imports #{:model/Collection
|
||||
:model/Database
|
||||
:model/Field
|
||||
@@ -2866,11 +2936,12 @@
|
||||
transforms.indexes
|
||||
{:team "Gadget"
|
||||
:ns-prefix "metabase.indexes"
|
||||
:api #{metabase.indexes.models.table-index metabase.indexes.reconcile}
|
||||
:api #{metabase.indexes.models.table-index metabase.indexes.reconcile metabase.indexes.schema}
|
||||
:uses #{driver
|
||||
lib.schema
|
||||
models
|
||||
util}
|
||||
util
|
||||
warehouses}
|
||||
:model-exports #{:model/TableIndex}}
|
||||
|
||||
transforms.indexes.rest
|
||||
@@ -2994,6 +3065,7 @@
|
||||
:api #{metabase.user-key-value.api
|
||||
metabase.user-key-value.init}
|
||||
:uses #{api
|
||||
api-scope
|
||||
lib.schema
|
||||
util
|
||||
util.config}}
|
||||
@@ -3037,6 +3109,7 @@
|
||||
:ns-prefix "metabase.users-rest"
|
||||
:api #{metabase.users-rest.api}
|
||||
:uses #{api
|
||||
api-scope
|
||||
appearance
|
||||
collections
|
||||
events
|
||||
@@ -3137,6 +3210,7 @@
|
||||
:api #{metabase.warehouses-rest.api}
|
||||
:uses #{analytics
|
||||
api
|
||||
api-scope
|
||||
app-db
|
||||
collections
|
||||
database-routing
|
||||
@@ -3156,13 +3230,11 @@
|
||||
sync
|
||||
upload
|
||||
util
|
||||
util.classloader
|
||||
util.config
|
||||
warehouse-schema-overlay
|
||||
warehouses
|
||||
warehouses.schema
|
||||
warehouses.secrets
|
||||
enterprise/permissions}
|
||||
warehouses.secrets}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Database
|
||||
@@ -3205,11 +3277,8 @@
|
||||
settings
|
||||
sync.analyze
|
||||
util
|
||||
util.classloader
|
||||
util.config
|
||||
warehouse-schema-overlay
|
||||
warehouses
|
||||
enterprise/permissions}
|
||||
warehouses}
|
||||
:model-exports #{:model/Dimension
|
||||
:model/Field
|
||||
:model/FieldUserSettings
|
||||
@@ -3230,6 +3299,7 @@
|
||||
:api #{metabase.warehouse-schema-rest.api}
|
||||
:uses #{analytics
|
||||
api
|
||||
api-scope
|
||||
app-db
|
||||
collections
|
||||
database-routing
|
||||
@@ -3279,6 +3349,7 @@
|
||||
collections
|
||||
dashboards
|
||||
driver
|
||||
indexed-entities
|
||||
lib
|
||||
lib.be
|
||||
lib.legacy-mbql
|
||||
@@ -3303,7 +3374,6 @@
|
||||
:model/Database
|
||||
:model/Field
|
||||
:model/ModelIndex
|
||||
:model/ModelIndexValue
|
||||
:model/Query
|
||||
:model/Revision
|
||||
:model/Segment
|
||||
@@ -3532,6 +3602,7 @@
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.content-translation.routes}
|
||||
:uses #{api
|
||||
api-scope
|
||||
content-translation
|
||||
embedding
|
||||
premium-features
|
||||
@@ -3561,6 +3632,7 @@
|
||||
metabase-enterprise.custom-viz-plugin.init
|
||||
metabase-enterprise.custom-viz-plugin.settings}
|
||||
:uses #{api
|
||||
api-scope
|
||||
events
|
||||
models
|
||||
premium-features
|
||||
@@ -3574,6 +3646,7 @@
|
||||
{:team "Embedding"
|
||||
:api #{metabase-enterprise.data-apps.api metabase-enterprise.data-apps.init metabase-enterprise.data-apps.sync}
|
||||
:uses #{api
|
||||
api-scope
|
||||
collections
|
||||
lib
|
||||
lib.be
|
||||
@@ -3635,10 +3708,7 @@
|
||||
enterprise/dependencies
|
||||
{:team "Graphy"
|
||||
:module-exports #{enterprise/dependencies.replacement}
|
||||
:api #{metabase-enterprise.dependencies.api
|
||||
metabase-enterprise.dependencies.core
|
||||
metabase-enterprise.dependencies.init
|
||||
metabase-enterprise.dependencies.models.dependency}
|
||||
:api #{metabase-enterprise.dependencies.api metabase-enterprise.dependencies.init metabase-enterprise.dependencies.models.dependency}
|
||||
:uses #{api
|
||||
app-db
|
||||
collections
|
||||
@@ -3649,14 +3719,18 @@
|
||||
lib
|
||||
lib.be
|
||||
lib.metadata
|
||||
lib.metric
|
||||
lib.schema
|
||||
measures
|
||||
models
|
||||
native-query-snippets
|
||||
permissions
|
||||
premium-features
|
||||
queries
|
||||
query-processor
|
||||
request
|
||||
revisions
|
||||
segments
|
||||
settings
|
||||
sql-tools
|
||||
task
|
||||
@@ -3664,7 +3738,8 @@
|
||||
transforms.base
|
||||
util
|
||||
util.graph
|
||||
warehouse-schema-overlay}
|
||||
warehouse-schema-overlay
|
||||
enterprise/permissions.sandbox}
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
@@ -3694,6 +3769,7 @@
|
||||
measures
|
||||
model-persistence
|
||||
models
|
||||
parameters
|
||||
queries
|
||||
segments
|
||||
task
|
||||
@@ -3726,8 +3802,13 @@
|
||||
enterprise/embedding
|
||||
{:team "Embedding"
|
||||
:ns-prefix "metabase-enterprise.embedding-hub"
|
||||
:api #{}
|
||||
:uses #{lib.schema
|
||||
:api #{metabase-enterprise.embedding-hub.api}
|
||||
:uses #{api
|
||||
appearance
|
||||
audit-app
|
||||
embedding
|
||||
lib.schema
|
||||
permissions
|
||||
premium-features
|
||||
util
|
||||
warehouse-schema-overlay
|
||||
@@ -3789,7 +3870,6 @@
|
||||
metabase-enterprise.metabot.api.routes
|
||||
metabase-enterprise.metabot.init}
|
||||
:uses #{api
|
||||
lib.be
|
||||
lib.schema
|
||||
metabot
|
||||
metabot.llm
|
||||
@@ -3799,14 +3879,8 @@
|
||||
settings
|
||||
task
|
||||
util
|
||||
enterprise/cloud.add-ons
|
||||
enterprise/dependencies
|
||||
enterprise/transforms.python}
|
||||
:model-imports #{:model/AiUsageLog
|
||||
:model/Card
|
||||
:model/PermissionsGroup
|
||||
:model/PermissionsGroupMembership
|
||||
:model/Transform}}
|
||||
enterprise/cloud.add-ons}
|
||||
:model-imports #{:model/AiUsageLog :model/PermissionsGroup :model/PermissionsGroupMembership}}
|
||||
|
||||
enterprise/metabot.agent-api
|
||||
{:team "Metabot"
|
||||
@@ -3961,8 +4035,9 @@
|
||||
enterprise/permissions.sandbox
|
||||
{:team "UX West"
|
||||
:ns-prefix "metabase-enterprise.sandbox"
|
||||
:api #{metabase-enterprise.sandbox.api.routes metabase-enterprise.sandbox.api.util}
|
||||
:api #{metabase-enterprise.sandbox.api.routes metabase-enterprise.sandbox.api.util metabase-enterprise.sandbox.schema}
|
||||
:uses #{api
|
||||
api-scope
|
||||
app-db
|
||||
audit-app
|
||||
driver
|
||||
@@ -4035,6 +4110,7 @@
|
||||
:model/Document
|
||||
:model/Field
|
||||
:model/FieldUserSettings
|
||||
:model/Glossary
|
||||
:model/Measure
|
||||
:model/NativeQuerySnippet
|
||||
:model/PythonLibrary
|
||||
@@ -4044,6 +4120,7 @@
|
||||
:model/Timeline
|
||||
:model/Transform
|
||||
:model/TransformTag
|
||||
:model/TransformTest
|
||||
:model/User}}
|
||||
|
||||
enterprise/search
|
||||
@@ -4278,6 +4355,31 @@
|
||||
:model-exports #{:model/Tenant}
|
||||
:model-imports #{:model/Collection :model/User}}
|
||||
|
||||
enterprise/transform-testing
|
||||
{:team "Graphy"
|
||||
:api #{metabase-enterprise.transform-testing.api metabase-enterprise.transform-testing.init}
|
||||
:uses #{api
|
||||
audit-app
|
||||
driver
|
||||
events
|
||||
lib
|
||||
lib.schema
|
||||
models
|
||||
premium-features
|
||||
revisions
|
||||
run-tracking
|
||||
sql-tools
|
||||
sql-tools.parsing
|
||||
task
|
||||
transforms.base
|
||||
util
|
||||
warehouses}
|
||||
:model-exports #{:model/TransformTest}
|
||||
:model-imports #{:model/Collection
|
||||
:model/Database
|
||||
:model/Transform
|
||||
:model/User}}
|
||||
|
||||
enterprise/transforms
|
||||
{:team "Graphy"
|
||||
:module-exports #{enterprise/transforms.inspector
|
||||
|
||||
21
.clj-kondo/macros/potemkin/namespaces.clj
Normal file
@@ -0,0 +1,21 @@
|
||||
(ns macros.potemkin.namespaces)
|
||||
|
||||
(defn- import-var-expansion
|
||||
"Expansion shared by all of the `import-*` replacements below. Synthetic \"docstring\" is needed to silence
|
||||
`:missing-docstring` linter."
|
||||
([sym]
|
||||
(import-var-expansion sym (-> sym name symbol)))
|
||||
([sym new-name]
|
||||
`(def ~new-name "docstring" ~sym)))
|
||||
|
||||
(defmacro import-def
|
||||
([sym] (import-var-expansion sym))
|
||||
([sym new-name] (import-var-expansion sym new-name)))
|
||||
|
||||
(defmacro import-fn
|
||||
([sym] (import-var-expansion sym))
|
||||
([sym new-name] (import-var-expansion sym new-name)))
|
||||
|
||||
(defmacro import-macro
|
||||
([sym] (import-var-expansion sym))
|
||||
([sym new-name] (import-var-expansion sym new-name)))
|
||||
@@ -14,10 +14,10 @@
|
||||
:consistent-alias 2
|
||||
:def-fn 1
|
||||
:deprecated-namespace 95
|
||||
:deprecated-var 96
|
||||
:deprecated-var 85
|
||||
:discouraged-java-method 4
|
||||
:discouraged-namespace 71
|
||||
:discouraged-var 130
|
||||
:discouraged-namespace 68
|
||||
:discouraged-var 132
|
||||
:duplicate-require 1
|
||||
:equals-true 1
|
||||
:main-without-gen-class 1
|
||||
@@ -26,13 +26,13 @@
|
||||
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
|
||||
:metabase/modules 24
|
||||
:metabase/namespace-name 4
|
||||
:metabase/prefer-with-dynamic-fn-redefs 28
|
||||
:metabase/prefer-with-dynamic-fn-redefs :unlimited
|
||||
:metabase/test-helpers-use-non-thread-safe-functions 18
|
||||
:metabase/validate-defendpoint-has-response-schema 427
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case 48
|
||||
:metabase/validate-defendpoint-route-uses-kebab-case 44
|
||||
:metabase/validate-deftest 9
|
||||
:missing-docstring 12
|
||||
:missing-docstring 4
|
||||
:missing-protocol-method 3
|
||||
:non-arg-vec-return-type-hint 1
|
||||
:reduce-without-init 3
|
||||
@@ -59,9 +59,10 @@
|
||||
:main-without-gen-class 1
|
||||
:metabase/defsetting-must-specify-export 1
|
||||
:metabase/defsetting-namespace 1
|
||||
:metabase/prefer-with-dynamic-fn-redefs 3
|
||||
:metabase/prefer-with-dynamic-fn-redefs 4
|
||||
:metabase/t2-query-namespace 2
|
||||
:metabase/table-or-field-query 1
|
||||
:metabase/unsafe-app-db-query 1
|
||||
:missing-docstring 1
|
||||
:private-call 1
|
||||
:redundant-ignore 1
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
[modules module ancestor]
|
||||
(boolean (some #{ancestor} (take-while some? (iterate #(parent-module modules %) module)))))
|
||||
|
||||
(defn module-team
|
||||
"Team owning `module`: its own `:team`, else its nearest ancestor's."
|
||||
[modules module]
|
||||
(some #(get-in modules [% :team])
|
||||
(take-while some? (iterate #(parent-module modules %) module))))
|
||||
|
||||
(defn- rest-module? [module]
|
||||
(re-find #"[.-]rest$" (str module)))
|
||||
|
||||
@@ -145,11 +151,11 @@
|
||||
[config ns-symb]
|
||||
(resolve-module (prefix->module config) ns-symb))
|
||||
|
||||
(defn- module-api-namespaces
|
||||
(defn module-api-namespaces
|
||||
"Set of API namespace symbols for a given module. `:any` means you can use anything, there are no API namespaces for
|
||||
this module (yet). If unspecified, the default is just the `<module>.core` namespace."
|
||||
[config module]
|
||||
(let [module-config (get-in config [:metabase/modules module :api])]
|
||||
this module (yet). If unspecified, the defaults are `<prefix>.api`, `<prefix>.core`, and `<prefix>.init`."
|
||||
[modules module]
|
||||
(let [module-config (get-in modules [module :api])]
|
||||
(cond
|
||||
(= module-config :any)
|
||||
nil
|
||||
@@ -158,7 +164,7 @@
|
||||
module-config
|
||||
|
||||
:else
|
||||
(let [ns-prefix (module-ns-prefix (:metabase/modules config) module)]
|
||||
(let [ns-prefix (module-ns-prefix modules module)]
|
||||
#{(symbol (str ns-prefix ".api"))
|
||||
(symbol (str ns-prefix ".core"))
|
||||
(symbol (str ns-prefix ".init"))}))))
|
||||
@@ -181,7 +187,7 @@
|
||||
|
||||
(defn- allowed-module-namespace? [config current-module ns-symb]
|
||||
(let [module (module config ns-symb)
|
||||
module-api-namespaces (module-api-namespaces config module)
|
||||
module-api-namespaces (module-api-namespaces (:metabase/modules config) module)
|
||||
module-friends (module-friends config module)]
|
||||
(or (nil? module-api-namespaces)
|
||||
(contains? module-api-namespaces ns-symb)
|
||||
|
||||
375
.clj-kondo/src/hooks/metabase/agent_message.clj
Normal file
@@ -0,0 +1,375 @@
|
||||
(ns hooks.metabase.agent-message
|
||||
"Hooks linting agent-facing prose built with `metabase.mcp.v2.message/msg` (`:metabase/agent-message-lines`) and the
|
||||
exits and helpers that carry it to the agent (`:metabase/agent-message-exit`). Each hook checks only when its
|
||||
linter's level is configured and not `:off`, and returns its input unchanged."
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn- level-on?
|
||||
[config linter-key]
|
||||
(not= :off (get-in config [:linters linter-key :level] :off)))
|
||||
|
||||
(defn- call-name
|
||||
"The symbol at the head of list `node`, or nil."
|
||||
[node]
|
||||
(when (hooks/list-node? node)
|
||||
(let [head (first (:children node))]
|
||||
(when (hooks/token-node? head)
|
||||
(let [v (hooks/sexpr head)]
|
||||
(when (symbol? v) v))))))
|
||||
|
||||
(defn- calls?
|
||||
"Whether `node` is a call to a var named `var-name` in one of `ns-names` (nil for an unqualified call)."
|
||||
[node ns-names var-name]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (= var-name (name sym))
|
||||
(contains? ns-names (namespace sym)))))
|
||||
|
||||
(def ^:private core-ns #{nil "clojure.core"})
|
||||
(def ^:private string-ns #{"str" "string" "clojure.string"})
|
||||
|
||||
(defn- literal-string
|
||||
"The string value of `node` when it is a string literal or a `str` call over string literals, else nil."
|
||||
[node]
|
||||
(cond
|
||||
(hooks/string-node? node)
|
||||
(hooks/sexpr node)
|
||||
|
||||
(and (calls? node core-ns "str")
|
||||
(every? hooks/string-node? (rest (:children node))))
|
||||
(apply str (map hooks/sexpr (rest (:children node))))))
|
||||
|
||||
(defn- digits->long
|
||||
"The value of a string of decimal digits."
|
||||
[s]
|
||||
;; Kondo's hook interpreter has no `parse-long`.
|
||||
(reduce (fn [n c] (+ (* 10 n) (- (int c) (int \0)))) 0 s))
|
||||
|
||||
(def ^:private format-specifier
|
||||
"A `java.util.Formatter` specifier; groups are explicit index, flags, width, precision, and conversion. The index is
|
||||
capped at nine digits, so a longer one reads as malformed rather than a number too large to work with."
|
||||
#"%(?:(\d{1,9})\$)?([-#+ 0,(<]*)(\d+)?(?:\.(\d+))?([tT]?[a-zA-Z%])")
|
||||
|
||||
(defn- consumed-args
|
||||
"`[index conversion]` for each argument-consuming specifier in `fmt`, with zero-based argument indexes. An index of
|
||||
-1 marks a reference no argument can satisfy: an explicit `0$`, or a `<` with no specifier before it to repeat."
|
||||
[fmt]
|
||||
(loop [[[_ explicit flags _width _precision conversion] & more] (re-seq format-specifier fmt)
|
||||
next-index 0
|
||||
last-index nil
|
||||
acc []]
|
||||
(if-not conversion
|
||||
acc
|
||||
(let [consumes? (not (contains? #{"%" "n"} conversion))
|
||||
index (cond
|
||||
(not consumes?) nil
|
||||
explicit (dec (digits->long explicit))
|
||||
(str/includes? flags "<") (or last-index -1)
|
||||
:else next-index)
|
||||
implicit? (and consumes? (not explicit) (not (str/includes? flags "<")))]
|
||||
(recur more
|
||||
(cond-> next-index implicit? inc)
|
||||
(or index last-index)
|
||||
(cond-> acc
|
||||
index (conj [index conversion])))))))
|
||||
|
||||
(def ^:private msg-linter :metabase/agent-message-lines)
|
||||
|
||||
(defn- reg-msg-finding!
|
||||
[node message]
|
||||
(hooks/reg-finding! (assoc (meta node) :message message :type msg-linter)))
|
||||
|
||||
(defn- line-break?
|
||||
"Whether format string `line` contains a line-breaking, control, or private-use character, or a `%n` specifier."
|
||||
[line]
|
||||
;; Private-use characters delimit the argument markers `render` formats with, so a line holding one could fake one.
|
||||
(boolean (or (re-find #"[\p{Cc}\p{Cf}\p{Co}\p{Zl}\p{Zp}]" line)
|
||||
(some #(= "n" (nth % 5)) (re-seq format-specifier line)))))
|
||||
|
||||
(defn- allowed-specifier?
|
||||
"Whether specifier match `specifier` renders its value as `render` passes it: a bare `%%`, a `%s` with no flags but
|
||||
`<`, or a `%d` with any flags and width but no precision. An explicit argument index is allowed on each."
|
||||
[[_ explicit flags width precision conversion]]
|
||||
(case conversion
|
||||
"%" (not (or explicit (seq flags) width precision))
|
||||
"s" (not (or width precision (seq (str/replace flags "<" ""))))
|
||||
"d" (not precision)
|
||||
false))
|
||||
|
||||
(defn- disallowed-specifier
|
||||
"The first specifier in format string `line` that isn't [[allowed-specifier?]], as it is written, or nil."
|
||||
[line]
|
||||
(some #(when-not (allowed-specifier? %) (first %)) (re-seq format-specifier line)))
|
||||
|
||||
(def ^:private valid-conversion
|
||||
"The conversions `java.util.Formatter` accepts, with each date/time conversion's required suffix."
|
||||
#"[bBhHsScCdoxXeEfgGaA%n]|[tT][HIklMSLNpzZsQBbhAaCYyjmdeRTrDFc]")
|
||||
|
||||
(defn- malformed-specifier?
|
||||
"Whether format string `line` has a `%` that doesn't begin a valid `java.util.Formatter` specifier."
|
||||
[line]
|
||||
(let [valid-removed (str/replace line format-specifier
|
||||
(fn [[specifier & groups]]
|
||||
(if (re-matches valid-conversion (last groups)) "" specifier)))]
|
||||
(str/includes? valid-removed "%")))
|
||||
|
||||
(defn- line-problem
|
||||
"The finding message for the first problem with a `msg` line, given its literal string or nil; nil when it has none."
|
||||
[line]
|
||||
(cond
|
||||
(nil? line)
|
||||
(str "Each line of a `msg` must be a string literal or a `str` of string literals; "
|
||||
"pass values as arguments after the vector.")
|
||||
|
||||
(line-break? line)
|
||||
(str "A `msg` line can't contain a line break, `%n`, or a control or private-use character; "
|
||||
"put each line in its own string.")
|
||||
|
||||
(malformed-specifier? line)
|
||||
"A `%` in a `msg` line must begin a format specifier; write a literal percent sign as `%%`."
|
||||
|
||||
:else
|
||||
(when-let [specifier (disallowed-specifier line)]
|
||||
(str "A `msg` line may only use `%s`, `%d`, or `%%`; `" specifier "` would alter or hide the value."))))
|
||||
|
||||
(defn- lint-lines!
|
||||
"Flag each of `line-nodes` whose literal string, the matching element of `lines`, has a problem."
|
||||
[line-nodes lines]
|
||||
(doseq [[line-node line] (map vector line-nodes lines)]
|
||||
(when-let [problem (line-problem line)]
|
||||
(reg-msg-finding! line-node problem))))
|
||||
|
||||
(defn- plural
|
||||
[n word]
|
||||
(str n " " word (when-not (= 1 n) "s")))
|
||||
|
||||
(defn- lint-arg-count!
|
||||
"Flag `msg` call `node` when a literal line refers to an argument that can't exist, or when its `args` don't number
|
||||
the arguments its literal `lines` consume."
|
||||
[node lines args]
|
||||
(let [indexes (map first (consumed-args (str/join "\n" lines)))
|
||||
expected (if (seq indexes) (inc (apply max indexes)) 0)
|
||||
given (count args)]
|
||||
(cond
|
||||
;; `String/format` throws on these, so the message would render with every part quoted instead.
|
||||
(some neg? indexes)
|
||||
(reg-msg-finding! node (str "A `msg` line refers to an argument that can't exist; "
|
||||
"argument indexes start at `1$`, and `%<` repeats the specifier before it."))
|
||||
|
||||
(not= expected given)
|
||||
(reg-msg-finding! node (format "The `msg` lines take %s but %d %s given."
|
||||
(plural expected "argument") given (if (= 1 given) "is" "are"))))))
|
||||
|
||||
(defn lint-msg
|
||||
"Flag `msg` calls whose lines aren't a literal vector of single-line literal strings, that hold a malformed
|
||||
specifier or a conversion other than `%s`, `%d`, or `%%`, or whose arguments don't match the lines' specifiers."
|
||||
[{:keys [node config] :as input}]
|
||||
(when (level-on? config msg-linter)
|
||||
(let [[fn-node lines-node & args] (:children node)]
|
||||
(if-not (and lines-node (hooks/vector-node? lines-node))
|
||||
(reg-msg-finding! (or lines-node fn-node)
|
||||
"`msg` takes a literal vector of line strings, one string per line of the message.")
|
||||
(let [line-nodes (:children lines-node)
|
||||
lines (map literal-string line-nodes)]
|
||||
(lint-lines! line-nodes lines)
|
||||
;; A malformed specifier can't be counted, and its line already has a finding.
|
||||
(when (and (every? some? lines) (not-any? malformed-specifier? lines))
|
||||
(lint-arg-count! node lines args))))))
|
||||
input)
|
||||
|
||||
(def ^:private exit-linter :metabase/agent-message-exit)
|
||||
|
||||
(defn- exit-enabled?
|
||||
[{:keys [config]}]
|
||||
(level-on? config exit-linter))
|
||||
|
||||
(defn- reg-exit-finding!
|
||||
[node message]
|
||||
(hooks/reg-finding! (assoc (meta node) :message message :type exit-linter)))
|
||||
|
||||
(defn- calls-core?
|
||||
"Whether `node` is a call to a `clojure.core` var or special form named in `var-names`."
|
||||
[node var-names]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (contains? var-names (name sym))
|
||||
(contains? core-ns (namespace sym)))))
|
||||
|
||||
(defn- calls-i18n?
|
||||
[node]
|
||||
(when-let [sym (call-name node)]
|
||||
(contains? #{"tru" "trs" "deferred-tru" "deferred-trs"} (name sym))))
|
||||
|
||||
(defn- calls-json-encode?
|
||||
"Whether `node` calls `encode` in a namespace or alias whose name ends in `json`."
|
||||
[node]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (= "encode" (name sym))
|
||||
(boolean (some-> (namespace sym) (str/ends-with? "json"))))))
|
||||
|
||||
(defn- builds-string?
|
||||
"Whether `node` is a string literal or a call that builds a string."
|
||||
[node]
|
||||
(or (hooks/string-node? node)
|
||||
(calls-core? node #{"str" "format" "pr-str" "ex-message"})
|
||||
(calls-i18n? node)
|
||||
(calls? node string-ns "join")
|
||||
(calls-json-encode? node)))
|
||||
|
||||
(defn- clause-results
|
||||
"The result nodes of `clauses`, alternating test and result nodes, with a trailing default when their count is odd."
|
||||
[clauses]
|
||||
(concat (take-nth 2 (rest clauses))
|
||||
(when (odd? (count clauses)) [(last clauses)])))
|
||||
|
||||
(defn- result-fn-marker?
|
||||
[node]
|
||||
(and (hooks/keyword-node? node) (= :>> (hooks/sexpr node))))
|
||||
|
||||
(defn- condp-results
|
||||
"The result nodes of `condp` `clauses`: each test's result, skipping `:>>` result functions, and any default."
|
||||
[clauses]
|
||||
(loop [[_test result & more :as clauses] clauses
|
||||
acc []]
|
||||
(cond
|
||||
(empty? clauses) acc
|
||||
(nil? (next clauses)) (conj acc (first clauses))
|
||||
(result-fn-marker? result) (recur (next more) acc)
|
||||
:else (recur more (conj acc result)))))
|
||||
|
||||
(defn- branch-results
|
||||
"The nodes branching form `node` can evaluate to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [args (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"if" "if-not" "if-let"}) (rest args)
|
||||
(calls-core? node #{"or"}) args
|
||||
(calls-core? node #{"cond"}) (clause-results args)
|
||||
(calls-core? node #{"case"}) (clause-results (rest args))
|
||||
(calls-core? node #{"condp"}) (condp-results (drop 2 args)))))
|
||||
|
||||
(defn- step-call
|
||||
"Threading step `node` as the call it stands for: a list as is, anything else as a call with no written arguments."
|
||||
[node]
|
||||
(if (hooks/list-node? node) node (hooks/list-node [node])))
|
||||
|
||||
(defn- threading-results
|
||||
"The nodes whose text-ness decides what threading form `node` evaluates to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [[init & steps] (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"->" "->>" "some->" "some->>"}) [(if steps (step-call (last steps)) init)]
|
||||
(calls-core? node #{"cond->" "cond->>"}) (cons init (map step-call (take-nth 2 (rest steps))))
|
||||
(calls-core? node #{"as->"}) [(or (last (rest steps)) init)]
|
||||
(calls-core? node #{"doto"}) [init])))
|
||||
|
||||
(defn- tail-result
|
||||
"The node body form `node` evaluates to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [args (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"when" "when-not" "when-let" "let"}) (when (next args) (last args))
|
||||
(calls-core? node #{"do"}) (last args))))
|
||||
|
||||
(defn- stringy?
|
||||
"Whether `node` syntactically evaluates to text: a string literal, a call that builds a string, or a threading,
|
||||
branching, or body form whose result is one."
|
||||
[node]
|
||||
(boolean
|
||||
(or (builds-string? node)
|
||||
(some stringy? (branch-results node))
|
||||
(some stringy? (threading-results node))
|
||||
(some-> (tail-result node) stringy?))))
|
||||
|
||||
(defn- lint-exit-text!
|
||||
"Flag `arg`, the message passed to `target` (a description of where it goes), when it is text rather than a `msg`."
|
||||
[arg target]
|
||||
(when (and arg (stringy? arg))
|
||||
(reg-exit-finding! arg (format (str "`%s` passes text to %s; "
|
||||
"build agent-facing text with `msg` so interpolated values are cleaned.")
|
||||
(pr-str (hooks/sexpr arg))
|
||||
target))))
|
||||
|
||||
(defn- lint-exit-arg!
|
||||
"Flag the argument at child `index` of call `node` when it is text rather than a `msg`."
|
||||
[node index]
|
||||
(lint-exit-text! (nth (:children node) index nil) (str "`" (name (call-name node)) "`")))
|
||||
|
||||
(defn lint-teaching-exit
|
||||
"Flag a call throwing or returning a teaching error whose message, the first argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 1))
|
||||
input)
|
||||
|
||||
(defn lint-jsonrpc-error
|
||||
"Flag a `jsonrpc-error` whose message, the third argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 3))
|
||||
input)
|
||||
|
||||
(defn lint-op-error
|
||||
"Flag an `op-error!` whose detail message, the third argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 3))
|
||||
input)
|
||||
|
||||
(defn lint-ellipsize
|
||||
"Flag an `ellipsize` whose message, the first argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 1))
|
||||
input)
|
||||
|
||||
(defn- map-value
|
||||
"The value node of keyword `k` in map literal `node`, or nil."
|
||||
[node k]
|
||||
(when (hooks/map-node? node)
|
||||
(some (fn [[key-node value-node]]
|
||||
(when (and (hooks/keyword-node? key-node) (= k (hooks/sexpr key-node)))
|
||||
value-node))
|
||||
(partition 2 (:children node)))))
|
||||
|
||||
(defn lint-list-content
|
||||
"Flag a `list-content`, or a wrapper taking its options map as the third argument too, whose literal options map
|
||||
has an `:empty-hint` that is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-text! (map-value (nth (:children node) 3 nil) :empty-hint)
|
||||
(str "`" (name (call-name node)) "`'s `:empty-hint`")))
|
||||
input)
|
||||
|
||||
(defn lint-success-content
|
||||
"Flag a `success-content` whose first argument is text rather than a payload or a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(let [text (second (:children node))]
|
||||
(when (and text (stringy? text))
|
||||
(reg-exit-finding! text (str "`success-content` takes data to JSON-encode or a `msg`; "
|
||||
"pass the payload itself, or build the text with `msg`.")))))
|
||||
input)
|
||||
|
||||
(defn- caller-facing-data?
|
||||
"Whether `node` is a map literal with a 4xx `:status-code` literal or an `error-code` key."
|
||||
[node]
|
||||
(and (hooks/map-node? node)
|
||||
(some (fn [[k v]]
|
||||
(when (hooks/keyword-node? k)
|
||||
(let [kw (hooks/sexpr k)]
|
||||
(or (= "error-code" (name kw))
|
||||
(and (= :status-code kw)
|
||||
(hooks/token-node? v)
|
||||
(let [status (hooks/sexpr v)]
|
||||
(and (number? status) (<= 400 status 499))))))))
|
||||
(partition 2 (:children node)))))
|
||||
|
||||
(defn lint-ex-info
|
||||
"Flag an `ex-info` whose data marks it as a caller-facing error."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(when (caller-facing-data? (nth (:children node) 2 nil))
|
||||
(reg-exit-finding! node (str "Caller-facing errors must be thrown with `throw-teaching-error` and a `msg`, "
|
||||
"so their text is rendered and cleaned."))))
|
||||
input)
|
||||
@@ -1,8 +1,22 @@
|
||||
(ns hooks.metabase.app-db.schema-migrations-test.impl
|
||||
(:require [clj-kondo.hooks-api :as hooks]))
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn test-migrations
|
||||
[{{[_ migration-range {[binding] :children} & body] :children} :node}]
|
||||
(defn- validate-mb-app-db-migrations-test!
|
||||
"CI runs the namespaces tagged `:mb/app-db-migrations-test` on a shard of their own, because a count-based split
|
||||
can't balance them (see `.github/workflows/app-db.yml`). An untagged one still runs, just on a shard sized for
|
||||
everything else."
|
||||
[{:keys [ns node]}]
|
||||
(when-not (:mb/app-db-migrations-test (meta ns))
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta node)
|
||||
:message (str "Namespaces that run migration tests (test-migrations) should be marked"
|
||||
" ^:mb/app-db-migrations-test [:metabase/validate-mb-app-db-migrations-test]")
|
||||
:type :metabase/validate-mb-app-db-migrations-test))))
|
||||
|
||||
(defn test-migrations!
|
||||
[{{[_ migration-range {[binding] :children} & body] :children} :node, :as x}]
|
||||
(validate-mb-app-db-migrations-test! x)
|
||||
(let [node* (hooks/list-node
|
||||
(list*
|
||||
(hooks/token-node `let)
|
||||
|
||||
@@ -29,6 +29,286 @@
|
||||
[filename]
|
||||
(boolean (and filename (re-find #"(?:^|/)test/" filename))))
|
||||
|
||||
(def ^:private value-operators
|
||||
"Operators whose second argument is a value rather than a column."
|
||||
'#{= not= != <> is-distinct-from is-not-distinct-from
|
||||
< > <= >= like not-like ilike not-ilike in not-in between not-between})
|
||||
|
||||
(defn- marked?
|
||||
"Whether `node` is an `[:auto/param v]` marker."
|
||||
[node]
|
||||
(and (hooks/vector-node? node)
|
||||
(= :auto/param (some-> (first (:children node)) hooks/sexpr))))
|
||||
|
||||
(defn- distinct-by
|
||||
"`coll` with only the first element for each distinct `(f element)`."
|
||||
[f coll]
|
||||
(->> coll (reduce (fn [[seen acc] x]
|
||||
(let [k (f x)]
|
||||
(if (contains? seen k) [seen acc] [(conj seen k) (conj acc x)])))
|
||||
[#{} []])
|
||||
second))
|
||||
|
||||
(def ^:private identifier-clauses
|
||||
"Clause keys whose entries name a column or a table rather than carrying a value.
|
||||
|
||||
A symbol here is a name, and `metabase.app-db.value-guard` refuses a marker in one, so reporting
|
||||
it would ask for a fix that throws at compile. `:order-by` and `:group-by` are included even
|
||||
though the guard allows a marker there: it compiles to `ORDER BY ?` and loses the ordering, so
|
||||
asking for one is still wrong."
|
||||
#{:select :select-distinct :select-top :from :join :left-join :right-join :inner-join :full-join
|
||||
:cross-join :update :insert-into :delete-from :returning :with :with-columns :using
|
||||
:order-by :group-by :partition-by :window})
|
||||
|
||||
(def ^:private expression-entry-clauses
|
||||
"Identifier clauses where index 0 of an `[expr alias]` entry is an expression, not a name.
|
||||
|
||||
HoneySQL binds a parameter there -- `SELECT ? AS a` -- so the guard accepts a marker, and mirrors
|
||||
this set as `expression-entry-clauses` in `metabase.app-db.value-guard`. Everything after index 0
|
||||
is an alias, and a bare entry is a name; the guard refuses a marker in both."
|
||||
#{:select :select-distinct :select-top :returning :partition-by})
|
||||
|
||||
(defn- comparison-node?
|
||||
"Whether `node` is a keyword-headed comparison -- the one shape inside an identifier clause that
|
||||
does hold values, as in the computed projection `[[:= :engine v] :is_match]`."
|
||||
[node]
|
||||
(and (hooks/vector-node? node)
|
||||
(let [op (some-> (first (:children node)) hooks/sexpr)]
|
||||
(and (keyword? op) (contains? value-operators (symbol (name op)))))))
|
||||
|
||||
(declare value-nodes)
|
||||
|
||||
(defn- identifier-clause-value-nodes
|
||||
"The value slots reachable inside an identifier clause: a subquery's own clauses, and the
|
||||
arguments of a comparison. The names themselves are not candidates."
|
||||
[node]
|
||||
(cond
|
||||
(hooks/map-node? node) (value-nodes node)
|
||||
(comparison-node? node) (value-nodes node)
|
||||
(hooks/vector-node? node) (mapcat identifier-clause-value-nodes (:children node))
|
||||
(hooks/list-node? node) (mapcat identifier-clause-value-nodes (:children node))
|
||||
:else nil))
|
||||
|
||||
(defn- expression-entry-value-nodes
|
||||
"The value slots of an [[expression-entry-clauses]] clause: index 0 of each `[expr alias]` entry."
|
||||
[node]
|
||||
(when (hooks/vector-node? node)
|
||||
(mapcat (fn [entry]
|
||||
(when (hooks/vector-node? entry)
|
||||
(when-let [expr (first (:children entry))]
|
||||
;; Keep the expression AND descend, so a bare symbol is reported and a computed
|
||||
;; projection or a subquery sitting there has its own values checked.
|
||||
(cons expr (value-nodes expr)))))
|
||||
(:children node))))
|
||||
|
||||
(defn- clause-value-nodes
|
||||
"The value slots of the value under clause key `clause`."
|
||||
[clause node]
|
||||
(cond
|
||||
(contains? expression-entry-clauses clause) (expression-entry-value-nodes node)
|
||||
(contains? identifier-clauses clause) (identifier-clause-value-nodes node)
|
||||
:else (value-nodes node)))
|
||||
|
||||
(defn- value-nodes
|
||||
"The nodes sitting in a value slot of `node`, following the clause shapes a query map uses."
|
||||
[node]
|
||||
(cond
|
||||
;; A query map -- walk its clause values, not its keys. A clause that names columns or tables
|
||||
;; contributes only what is nested inside it.
|
||||
(hooks/map-node? node)
|
||||
(mapcat (fn [[k v]]
|
||||
(if (hooks/keyword-node? k)
|
||||
(clause-value-nodes (hooks/sexpr k) v)
|
||||
(value-nodes v)))
|
||||
(partition 2 (:children node)))
|
||||
|
||||
(hooks/vector-node? node)
|
||||
(let [[head & args] (:children node)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(cond
|
||||
;; A marker IS the value slot -- never descend into its payload, or the marked value
|
||||
;; gets reported as though it were bare.
|
||||
(marked? node)
|
||||
nil
|
||||
|
||||
(contains? #{:and :or :not} op)
|
||||
(mapcat value-nodes args)
|
||||
|
||||
;; `[:= col v]`, `[:in col vs]`, `[:between col lo hi]` -- the first argument is the
|
||||
;; column and the rest are values, whatever the arity. Keep each value AND descend into it,
|
||||
;; so a subquery in a value slot -- `[:in :id {:select ... :where [:= :z z]}]` -- has its
|
||||
;; own values checked too. Descending alone would drop the plain-token case.
|
||||
(and (keyword? op) (contains? value-operators (symbol (name op))))
|
||||
(mapcat #(cons % (value-nodes %)) (rest args))
|
||||
|
||||
;; A function-call form -- `[:lower v]` -- or a literal collection -- `[a b]` in an `:in`.
|
||||
;; Both hold values, so yield the children themselves as candidates as well as
|
||||
;; descending, since a token child returns nothing on its own.
|
||||
:else
|
||||
(mapcat #(cons % (value-nodes %)) (:children node))))
|
||||
|
||||
;; A clause built conditionally -- `(when flag [:= :col v])`, `(if ... )`, `(cond-> ...)`. The
|
||||
;; value slots are inside, so walk the children rather than stopping. Without this a value
|
||||
;; wrapped in `when` is invisible to the check, which is a false negative in a security lint.
|
||||
;;
|
||||
;; An `assoc` call gets its clause context back: `(assoc q :order-by [[col dir]])` is the same
|
||||
;; clause as `{:order-by [[col dir]]}`, but written outside a map literal, where the walk would
|
||||
;; otherwise fall back to shape alone and report the column and the direction as values.
|
||||
;; `cond->` is covered because the walk descends into the nested `assoc`.
|
||||
(hooks/list-node? node)
|
||||
(let [children (vec (:children node))
|
||||
assoc? (= 'assoc (some-> (first children) hooks/sexpr))
|
||||
;; index of a clause value -> its clause key
|
||||
clauses (when assoc?
|
||||
(into {} (keep-indexed (fn [i child]
|
||||
(when (and (hooks/keyword-node? child)
|
||||
(contains? identifier-clauses (hooks/sexpr child))
|
||||
(< (inc i) (count children)))
|
||||
[(inc i) (hooks/sexpr child)]))
|
||||
children)))]
|
||||
(mapcat (fn [i child]
|
||||
(if-let [clause (get clauses i)]
|
||||
(clause-value-nodes clause child)
|
||||
(value-nodes child)))
|
||||
(range)
|
||||
children))
|
||||
|
||||
:else nil))
|
||||
|
||||
(def ^:private write-fns
|
||||
"Calls whose values are written rather than filtered on. A value here is not a where-clause value
|
||||
an attacker can turn into SQL structure, and a `define-before-insert` hook may read it before the
|
||||
query compiles, so a marker would break it rather than protect anything."
|
||||
'#{insert! insert-returning-instance! insert-returning-instances! insert-returning-pk!
|
||||
insert-returning-pks! save!})
|
||||
|
||||
(defn- read-call?
|
||||
"Whether `node` is a query call whose values are filtered on rather than written."
|
||||
[node]
|
||||
(let [f (some-> (first (:children node)) hooks/sexpr)]
|
||||
(not (contains? write-fns (some-> f name symbol)))))
|
||||
|
||||
(def ^:private clause-keys
|
||||
"Query-map clause keys. A map keyed by these is a query map rather than a map of conditions."
|
||||
#{:select :select-distinct :from :where :join :left-join :right-join :inner-join :full-join
|
||||
:cross-join :group-by :having :order-by :limit :offset :for :union :union-all :with
|
||||
:with-columns :returning :values :set})
|
||||
|
||||
(defn- query-map-node?
|
||||
"Whether `node` is a query map rather than a map of column/value conditions.
|
||||
|
||||
`(t2/delete! :model/X {:where [:= :k k]})` passes a query map, whose values [[value-nodes]]
|
||||
already walks -- treating it as conditions too would report every value twice."
|
||||
[node]
|
||||
(boolean (some #(and (hooks/keyword-node? %) (contains? clause-keys (hooks/sexpr %)))
|
||||
(take-nth 2 (:children node)))))
|
||||
|
||||
(def ^:private changes-map-fns
|
||||
"Conditions-map fns whose arglist also ends in a changes map, so a lone trailing map is changes."
|
||||
'#{update! update-or-insert!})
|
||||
|
||||
(def ^:private conditions-map-fns
|
||||
"Calls whose first argument after the model is a map of CONDITIONS rather than a query map.
|
||||
|
||||
`(t2/update! :model/X {:key k} {:v 1})` filters on `k`, so it is a where-clause value -- but it
|
||||
reaches the query as a plain map entry rather than a `:where` clause, so neither the query-map
|
||||
walker nor the kv-arg walker sees it. It is a shape the sweep leaves in place, so it has to be
|
||||
checked here."
|
||||
'#{update! update-or-insert! delete!})
|
||||
|
||||
(defn- conditions-map-value-nodes
|
||||
"The values of a conditions map passed to one of [[conditions-map-fns]]."
|
||||
[f args]
|
||||
(when (contains? conditions-map-fns (some-> f name symbol))
|
||||
(let [after-model (->> args
|
||||
(drop-while #(not (and (hooks/keyword-node? %)
|
||||
(= "model" (namespace (hooks/sexpr %))))))
|
||||
rest)]
|
||||
;; Only the map immediately after the model is conditions. A later map is the changes map,
|
||||
;; whose values are written rather than filtered on, and a written value is never marked: a
|
||||
;; column's `:in` transform runs on the marker itself and stores it as data.
|
||||
;; `update!`'s arglist ENDS in the changes map, so its first map is conditions only when
|
||||
;; another argument follows: `(t2/update! model {:v written})` is the two-arity call whose
|
||||
;; single map is CHANGES, so flagging it would ask for a marker that corrupts the write.
|
||||
;; `delete!` has no changes map, so its map is always conditions.
|
||||
(when-let [m (when (or (not (contains? changes-map-fns (some-> f name symbol)))
|
||||
(next after-model))
|
||||
(first after-model))]
|
||||
(when (and (hooks/map-node? m) (not (query-map-node? m)))
|
||||
(mapcat (fn [v]
|
||||
;; The column comes from the map key, so an operator form here holds only
|
||||
;; values -- `{:key [:in ks]}` -- exactly as a kv-arg pair does.
|
||||
(if (and (hooks/vector-node? v) (not (marked? v)))
|
||||
(let [[head & op-args] (:children v)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(if (and (keyword? op) (contains? value-operators (symbol (name op))))
|
||||
(mapcat #(cons % (value-nodes %)) op-args)
|
||||
(cons v (value-nodes v))))
|
||||
(cons v (value-nodes v))))
|
||||
(take-nth 2 (rest (:children m)))))))))
|
||||
|
||||
(defn- kv-arg-value-nodes
|
||||
"The value nodes of a call written as `:column value` pairs.
|
||||
|
||||
`(t2/select :model/X :locale locale)`. Several of these fns take an argument before the model --
|
||||
`(t2/select-one-fn :value :model/X :key k)` -- so the pairs do not start at a fixed offset; they
|
||||
start after the `:model/...` keyword. A call that does not name a literal model is not checked.
|
||||
|
||||
A trailing query map is not a pair, and its values are reached by [[value-nodes]] instead. A
|
||||
value that is itself an operator form -- `:id [:in ids]` -- has its own values walked, so the
|
||||
collection inside is checked rather than the form."
|
||||
[args]
|
||||
(let [after-model (->> args
|
||||
(drop-while #(not (and (hooks/keyword-node? %)
|
||||
(= "model" (namespace (hooks/sexpr %))))))
|
||||
rest)]
|
||||
(->> after-model
|
||||
;; `partition-all` rather than `partition` so a trailing odd argument (a query map, which
|
||||
;; is not a pair) is still seen and skipped by the keyword-node? test below.
|
||||
(partition-all 2)
|
||||
(mapcat (fn [[k v]]
|
||||
(when (and v (hooks/keyword-node? k))
|
||||
(if-not (hooks/vector-node? v)
|
||||
[v]
|
||||
(let [[head & op-args] (:children v)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(cond
|
||||
;; Already marked -- the marker is the value slot.
|
||||
(marked? v) [v]
|
||||
|
||||
;; `:id [:in ids]`. Toucan folds the column in from the pair key, so
|
||||
;; EVERY argument here is a value -- unlike a query-map clause, where
|
||||
;; the first argument is the column.
|
||||
(and (keyword? op)
|
||||
(contains? value-operators (symbol (name op))))
|
||||
(mapcat #(cons % (value-nodes %)) op-args)
|
||||
|
||||
:else (cons v (value-nodes v)))))))))))
|
||||
|
||||
(defn- lint-unmarked-values!
|
||||
"Register a finding for each argument of the enclosing function that reaches a value slot unmarked.
|
||||
|
||||
Only a symbol is reported. A literal cannot carry a request value, and a value built inside the
|
||||
function is out of reach of a check that does not follow it across a call."
|
||||
[node]
|
||||
(doseq [value (let [args (rest (:children node))
|
||||
f (some-> (first (:children node)) hooks/sexpr)]
|
||||
;; The three walkers overlap on some shapes -- a multi-arg operator in a kv-arg
|
||||
;; reaches both `value-nodes` and `kv-arg-value-nodes` -- so dedupe by source
|
||||
;; position rather than trying to keep them disjoint.
|
||||
(->> (concat (mapcat value-nodes args)
|
||||
(kv-arg-value-nodes args)
|
||||
(conditions-map-value-nodes f args))
|
||||
(distinct-by #(select-keys (meta %) [:row :col]))))
|
||||
:when (and (hooks/token-node? value)
|
||||
(symbol? (hooks/sexpr value))
|
||||
(not (marked? value)))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta value)
|
||||
:message (format "`%s` reaches a SQL value slot unmarked. Write it as [:auto/param %s] so it is bound as a parameter."
|
||||
(hooks/sexpr value) (hooks/sexpr value))
|
||||
:type :metabase/unsafe-app-db-query))))
|
||||
|
||||
(defn lint-query-call
|
||||
"Register a `:metabase/t2-query-namespace` finding when a Toucan 2 query call appears outside a `<module>.db`
|
||||
namespace."
|
||||
@@ -43,4 +323,9 @@
|
||||
(hooks/sexpr fn-node))
|
||||
:type :metabase/t2-query-namespace))))
|
||||
((requiring-resolve 'hooks.metabase.warehouse-schema-overlay.table-or-field-query/lint-read) input)
|
||||
(when (and ns
|
||||
(db-namespace? (modules/config input) ns)
|
||||
(not (test-file? filename))
|
||||
(read-call? node))
|
||||
(lint-unmarked-values! node))
|
||||
input)
|
||||
|
||||
334
.clj-kondo/test/hooks/metabase/agent_message_test.clj
Normal file
@@ -0,0 +1,334 @@
|
||||
(ns hooks.metabase.agent-message-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.agent-message :as agent-message]))
|
||||
|
||||
(defn- findings
|
||||
"Run `hook-fn` over `form`, a form or its source string, with the linters at `level` (unconfigured when nil) in
|
||||
namespace `ns` of file `filename`; return the findings' messages."
|
||||
([hook-fn form]
|
||||
(findings hook-fn form :warning))
|
||||
([hook-fn form level]
|
||||
(findings hook-fn form level 'metabase.mcp.v2.tools.browse "src/metabase/mcp/v2/tools/browse.clj"))
|
||||
([hook-fn form level ns filename]
|
||||
(let [config (if level
|
||||
{:linters {:metabase/agent-message-lines {:level level}
|
||||
:metabase/agent-message-exit {:level level}}}
|
||||
{})]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config config
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(let [input {:node (hooks/parse-string (if (string? form) form (pr-str form)))
|
||||
:ns ns
|
||||
:filename filename
|
||||
:config config}
|
||||
output (hook-fn input)]
|
||||
(is (identical? (:node input) (:node output))
|
||||
"the hook must return the node unchanged so Kondo's normal analysis still runs")
|
||||
(mapv :message @(:findings clj-kondo.impl.utils/*ctx*)))))))
|
||||
|
||||
(def ^:private hook-cases
|
||||
"`[hook form]` pairs that each report one finding when their linter is enabled."
|
||||
[[agent-message/lint-msg '(msg lines x)]
|
||||
[agent-message/lint-teaching-exit '(throw-teaching-error "x")]
|
||||
[agent-message/lint-jsonrpc-error '(jsonrpc-error id -32602 "x")]
|
||||
[agent-message/lint-success-content '(success-content "x")]
|
||||
[agent-message/lint-ex-info "(ex-info \"x\" {:status-code 404})"]
|
||||
[agent-message/lint-op-error '(op-error! idx "add_link" "x")]
|
||||
[agent-message/lint-ellipsize '(ellipsize "x" 300)]
|
||||
[agent-message/lint-list-content '(list-content data total {:empty-hint "x"})]])
|
||||
|
||||
(defn- linter-enabled-in-ns?
|
||||
"Whether `config` enables `linter` in namespace `ns-name` through an ns-group's `:config-in-ns` entry."
|
||||
[config linter ns-name]
|
||||
(boolean
|
||||
(some (fn [{:keys [pattern name]}]
|
||||
(and (string? pattern)
|
||||
(re-find (re-pattern pattern) ns-name)
|
||||
(not= :off (get-in config [:config-in-ns name :linters linter :level] :off))))
|
||||
(:ns-groups config))))
|
||||
|
||||
(deftest ^:parallel config-scope-test
|
||||
(let [config (edn/read-string (slurp ".clj-kondo/config.edn"))]
|
||||
(testing "GHY-4544: `msg` lines are checked in every OSS and enterprise MCP source namespace, and no test one"
|
||||
(doseq [[ns-name enabled?] {"metabase.mcp.ui-resource" true
|
||||
"metabase.mcp.transport" true
|
||||
"metabase.mcp.v2.tools.browse" true
|
||||
"metabase-enterprise.mcp.usage" true
|
||||
"metabase-enterprise.mcp.v2.tools.browse" true
|
||||
"metabase.mcp.ui-resource-test" false
|
||||
"metabase.mcp.v2.tools.browse-test" false
|
||||
"metabase.mcp.test-util" false
|
||||
"metabase-enterprise.mcp.usage-test" false
|
||||
"metabase-enterprise.mcp.v2.tools.browse-test" false
|
||||
"metabase.queries.models.card" false}]
|
||||
(testing ns-name
|
||||
(is (= enabled? (linter-enabled-in-ns? config :metabase/agent-message-lines ns-name))))))
|
||||
(testing "GHY-4544: exits are checked in MCP transport and v2 source namespaces, whose 4xx errors reach the agent"
|
||||
(doseq [[ns-name enabled?] {"metabase.mcp.transport" true
|
||||
"metabase.mcp.v2.common" true
|
||||
"metabase.mcp.v2.tools.browse" true
|
||||
"metabase-enterprise.mcp.v2.common" true
|
||||
"metabase.mcp.settings" false
|
||||
"metabase.mcp.validation" false
|
||||
"metabase-enterprise.mcp.usage" false
|
||||
"metabase.mcp.transport-test" false
|
||||
"metabase.mcp.v2.tools.browse-test" false
|
||||
"metabase-enterprise.mcp.v2.tools.browse-test" false
|
||||
"metabase.queries.models.card" false}]
|
||||
(testing ns-name
|
||||
(is (= enabled? (linter-enabled-in-ns? config :metabase/agent-message-exit ns-name))))))
|
||||
(testing "GHY-4544: `:empty-hint` is checked on `list-content` and on wrappers passing their options map to it"
|
||||
(doseq [fn-sym '[metabase.mcp.v2.common/list-content
|
||||
metabase.mcp.v2.tools.browse/paged-list-content]]
|
||||
(testing fn-sym
|
||||
(is (= 'hooks.metabase.agent-message/lint-list-content
|
||||
(get-in config [:hooks :analyze-call fn-sym]))))))
|
||||
(testing (str "GHY-4544: `check-execute-sql-enabled!` isn't checked, since it interpolates its subject into "
|
||||
"its own `msg` and a plain string is the intended way to name the tool")
|
||||
(is (nil? (get-in config [:hooks
|
||||
:analyze-call
|
||||
(symbol "metabase.mcp.v2.queries" "check-execute-sql-enabled!")]))))))
|
||||
|
||||
(deftest ^:parallel scope-test
|
||||
(testing "GHY-4544: the hooks leave scoping to config, reporting in any namespace where their linter is enabled"
|
||||
(doseq [[hook form] hook-cases
|
||||
[ns filename] [['metabase.mcp.v2.tools.browse "src/metabase/mcp/v2/tools/browse.clj"]
|
||||
['metabase.queries.models.card "src/metabase/queries/models/card.clj"]
|
||||
['metabase.mcp.v2.tools.browse-test "test/metabase/mcp/v2/tools/browse_test.clj"]]]
|
||||
(testing (str ns " " (pr-str form))
|
||||
(is (= 1 (count (findings hook form :warning ns filename)))))))
|
||||
(testing "nothing is reported when the linter is off or unconfigured"
|
||||
(doseq [[hook form] hook-cases
|
||||
level [:off nil]]
|
||||
(testing (str level " " (pr-str form))
|
||||
(is (empty? (findings hook form level)))))))
|
||||
|
||||
(deftest ^:parallel msg-test
|
||||
(testing "GHY-4544: a vector of single-line string literals with matching arguments is accepted"
|
||||
(is (empty? (findings agent-message/lint-msg
|
||||
'(msg ["Table %s: %d of %d fields." "Continue with `offset: %d`."] t n total next)))))
|
||||
(testing "specifiers that consume no argument aren't counted, and an explicit index can repeat an argument"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["100%% done."]))))
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["%1$s, then %1$s again, then %<s"] x)))))
|
||||
(testing "the lines must be a literal vector"
|
||||
(is (=? [#".*vector.*"]
|
||||
(findings agent-message/lint-msg '(msg lines x))))
|
||||
(is (=? [#".*vector.*"]
|
||||
(findings agent-message/lint-msg '(msg "one line" x)))))
|
||||
(testing "a line may be split across a `str` of string literals"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg [(str "Table %s has " "%d fields.")] t n))))
|
||||
(is (=? [#".*1 argument.*2.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "only " "%s")] a b)))))
|
||||
(testing "each line must be a string literal or a `str` of string literals"
|
||||
(is (=? [#".*string literal or a `str` of string literals.*"]
|
||||
(findings agent-message/lint-msg '(msg ["ok" line]))))
|
||||
(is (=? [#".*string literal or a `str` of string literals.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "split " x)])))))
|
||||
(testing "a line can't contain a line break, control, or private-use character; each line is its own string"
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["first\nsecond"]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "first\n" "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "first" (char 0x2028) "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["tab\tseparated"]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["done.%n"]))))
|
||||
(testing "GHY-4544: a private-use character, which the truncation markers use, is flagged too"
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "first" (char 0xE000) "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "marked" (char 0xF8FF))]))))))
|
||||
(testing "the argument count must match the specifiers across all lines"
|
||||
(is (=? [#".*2 arguments.*1.*"]
|
||||
(findings agent-message/lint-msg '(msg ["%s and" "%s"] a))))
|
||||
(is (=? [#".*1 argument.*2.*"]
|
||||
(findings agent-message/lint-msg '(msg ["only %s"] a b)))))
|
||||
(testing (str "GHY-4544: a reference `String/format` can't resolve is flagged, since the runtime would fall back "
|
||||
"to rendering every part quoted")
|
||||
(testing "`%<` with no specifier before it to repeat, which counts as no argument at all"
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %<s"]))))
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["100%% done," "then %<d"])))))
|
||||
(testing "an explicit index of zero, which is below the first argument"
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %0$s"]))))
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %0$s"] x)))))
|
||||
(testing "a `%<` that repeats the first argument is still accepted"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["%s then %<s"] x)))))))
|
||||
|
||||
(deftest ^:parallel msg-specifier-test
|
||||
(testing "GHY-4544: only `%s` with no flags but `<`, `%d` with flags and width, and `%%` are allowed"
|
||||
(doseq [line ["%s" "%d" "%1$s" "%,d" "%05d" "%-8d" "%(d" "%1$,10d"
|
||||
"%s again %<s" "%d again %<d" "%s (100%%)"]]
|
||||
(testing line
|
||||
(is (empty? (findings agent-message/lint-msg (list 'msg [(str "Value: " line)] 'x)))))))
|
||||
(testing "GHY-4544: any other conversion, or a `%s` or `%d` that reshapes its value, is flagged"
|
||||
(doseq [line ["%b" "%h" "%c" "%x" "%X" "%f" "%e" "%o" "%g" "%a" "%tY" "%TY" "%B"
|
||||
"%S" "%.20s" "%10s" "%-5s" "%#s" "%1$.3s" "%-10S" "%.1f" "%.2d" "%1$.2d"]]
|
||||
(testing line
|
||||
(is (=? [#"A `msg` line may only use `%s`, `%d`, or `%%`; .* would alter or hide the value\."]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "Value: " line)] 'x)))))))
|
||||
(testing "GHY-4544: a `%` that doesn't begin a valid format specifier is flagged, since `String/format` rejects it"
|
||||
(doseq [line ["Braces %}" "Dot %." "Trailing %" "Space % " "Unknown %q" "Bad date %tq" "%" "%1$"
|
||||
;; GHY-4544: an argument index too large to be one is malformed, not an arithmetic overflow
|
||||
"%999999999999999999999999$s"]]
|
||||
(testing (pr-str line)
|
||||
(is (=? [#"A `%` in a `msg` line must begin a format specifier.*`%%`.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [line]))))))
|
||||
(testing "an index at the digit cap still parses, and is reported as an argument count"
|
||||
(is (=? [#".*999999999 arguments.*"]
|
||||
(findings agent-message/lint-msg '(msg ["%999999999$s"] x))))))
|
||||
(testing "valid specifiers, including an escaped percent sign, aren't flagged as malformed"
|
||||
(doseq [form ['(msg ["100%% done"])
|
||||
'(msg ["%,d rows"] n)
|
||||
'(msg ["%1$s, then %<s"] x)
|
||||
'(msg [(str "50%" "% of %s")] x)]]
|
||||
(testing (pr-str form)
|
||||
(is (empty? (findings agent-message/lint-msg form)))))))
|
||||
|
||||
(def ^:private stringy-texts
|
||||
"Message arguments built as text rather than with `msg`."
|
||||
['"Query failed."
|
||||
'(str "Query failed: " err)
|
||||
'(format "Table %s not found" (pr-str t))
|
||||
'(cond-> (json/encode body) message (str "\n" message))
|
||||
'(if x "a" (str "b" y))
|
||||
'(if x (msg ["a"]) (str "b" y))
|
||||
'(when-let [e (:error r)] (str/join ", " e))
|
||||
'(or (:message r) (tru "Unknown error"))
|
||||
'(let [s (pr-str x)] (str "Bad value " s))
|
||||
'(cond (:a x) (msg ["a"]) :else "b")
|
||||
'(condp = x :a (str "a" y) (msg ["b"]))
|
||||
'(condp = x :a (msg ["a"]) "b")
|
||||
'(case x :a (msg ["a"]) "b")
|
||||
'(case x :a "a" :b (msg ["b"]))
|
||||
'(->> bad (map name) (str/join ", "))
|
||||
'(-> x (str " suffix"))
|
||||
'(-> x name str)
|
||||
'(some-> e ex-message)
|
||||
'(some->> xs (map name) (clojure.string/join ", "))
|
||||
'(cond->> xs x (map name) y (str/join ", "))
|
||||
'(cond-> "x" y (subs 1))
|
||||
'(as-> x v (assoc v :a 1) (json/encode v))
|
||||
'(doto (str "a" b) println)
|
||||
'(let [s x] (-> s (format y)))])
|
||||
|
||||
(def ^:private message-texts
|
||||
"Message arguments that aren't syntactically text."
|
||||
['(msg ["Table %s not found."] t)
|
||||
'message
|
||||
'(if x (msg ["a"]) (msg ["b"]))
|
||||
'(render-error e)
|
||||
'(cond (str x) (msg ["a"]) :else (msg ["b"]))
|
||||
'(condp = x "a" (msg ["a"]) (msg ["b"]))
|
||||
'(condp get x "a" :>> render-error (msg ["b"]))
|
||||
'(case x "a" (msg ["a"]) ("b" "c") (msg ["b"]))
|
||||
'(-> "x" count)
|
||||
'(->> "x" (map str) set)
|
||||
'(some-> (str "x") render-error)
|
||||
'(-> x)
|
||||
'(-> e :message)
|
||||
'(cond-> {:a 1} x (assoc :b "c"))
|
||||
'(as-> "x" v (count v))
|
||||
'(doto x (println "a"))])
|
||||
|
||||
(deftest ^:parallel teaching-exit-test
|
||||
(doseq [fn-name ["throw-teaching-error" "error-content"]]
|
||||
(testing (str "GHY-4544: text passed to `" fn-name "` is flagged")
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [(re-pattern (str ".*passes text to `" fn-name "`.*`msg`.*"))]
|
||||
(findings agent-message/lint-teaching-exit (list (symbol "common" fn-name) text)))))))
|
||||
(testing (str "`" fn-name "` accepts a `msg`, a symbol, and other calls")
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-teaching-exit
|
||||
(list (symbol fn-name) text {:status-code 404}))))))))
|
||||
(testing "only the message argument is checked"
|
||||
(is (empty? (findings agent-message/lint-teaching-exit '(error-content (msg ["x"]) (str "code"))))))
|
||||
(testing "nothing is reported when the linter is off"
|
||||
(is (empty? (findings agent-message/lint-teaching-exit '(throw-teaching-error "x") :off)))))
|
||||
|
||||
(def ^:private helper-cases
|
||||
"`[hook fn-name form-fn]`: a message-taking helper's hook, the name its finding cites, and a function of a message
|
||||
returning a call that passes it to the helper."
|
||||
[[agent-message/lint-op-error "op-error!" #(list 'dashboard-ops/op-error! 'idx "add_link" %)]
|
||||
[agent-message/lint-ellipsize "ellipsize" #(list 'common/ellipsize % 300)]
|
||||
[agent-message/lint-list-content "list-content" #(list 'common/list-content 'rows 0 {:offset 0 :empty-hint %})]
|
||||
[agent-message/lint-list-content "paged-list-content"
|
||||
#(list 'paged-list-content 'args 'rows {:empty-hint %} 'identity)]])
|
||||
|
||||
(deftest ^:parallel message-helper-test
|
||||
(doseq [[hook fn-name form-fn] helper-cases]
|
||||
(testing (str "GHY-4544: text passed as `" fn-name "`'s message is flagged")
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [(re-pattern (str ".*passes text to `" fn-name "`.*`msg`.*"))]
|
||||
(findings hook (form-fn text)))))))
|
||||
(testing (str "`" fn-name "` accepts a `msg`, a symbol, and other calls as its message")
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings hook (form-fn text))))))))
|
||||
(testing "only the message argument is checked"
|
||||
(is (empty? (findings agent-message/lint-op-error '(op-error! (str "0") (str "op") (msg ["x"])))))
|
||||
(is (empty? (findings agent-message/lint-ellipsize '(ellipsize (msg ["x"]) (str "300")))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content (str "data") 0 {:param (str "x")}))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content data 0 opts))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content data 0))))))
|
||||
|
||||
(deftest ^:parallel jsonrpc-error-test
|
||||
(testing "GHY-4544: text passed as the JSON-RPC error message is flagged"
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [#".*passes text to `jsonrpc-error`.*`msg`.*"]
|
||||
(findings agent-message/lint-jsonrpc-error (list 'transport/jsonrpc-error 'id -32602 text)))))))
|
||||
(testing "a `msg`, symbol, or other call as the message is accepted"
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-jsonrpc-error (list 'jsonrpc-error 'id -32602 text)))))))
|
||||
(testing "only the third argument is the message"
|
||||
(is (empty? (findings agent-message/lint-jsonrpc-error '(jsonrpc-error "id" (str "code") (msg ["x"])))))))
|
||||
|
||||
(deftest ^:parallel success-content-test
|
||||
(testing "GHY-4544: text passed to `success-content` is flagged"
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [#"`success-content` takes data to JSON-encode or a `msg`.*"]
|
||||
(findings agent-message/lint-success-content (list 'common/success-content text)))))))
|
||||
(testing "a payload, a `msg`, or a symbol is accepted"
|
||||
(doseq [text (conj message-texts '{:data rows} '[a b])]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-success-content (list 'success-content text))))))
|
||||
(is (empty? (findings agent-message/lint-success-content
|
||||
'(success-content {:data rows} (str "structured")))))))
|
||||
|
||||
(deftest ^:parallel ex-info-test
|
||||
(testing "GHY-4544: an ex-info carrying a 4xx status code or an error code is flagged"
|
||||
(doseq [form ["(ex-info \"Card not found\" {:status-code 404})"
|
||||
"(ex-info (str \"Bad \" x) {:status-code 400 :field f})"
|
||||
"(ex-info \"Nope\" {::common/error-code c})"
|
||||
"(ex-info \"Nope\" {:error-code c})"
|
||||
"(ex-info (msg [\"Nope\"]) {:error-code c})"]]
|
||||
(testing form
|
||||
(is (=? [#".*`throw-teaching-error` and a `msg`.*"]
|
||||
(findings agent-message/lint-ex-info form))))))
|
||||
(testing "other ex-info calls aren't flagged"
|
||||
(doseq [form ["(ex-info \"boom\" {:status-code 500})"
|
||||
"(ex-info \"boom\" {:status-code code})"
|
||||
"(ex-info \"boom\" {:foo 1})"
|
||||
"(ex-info \"boom\" data)"
|
||||
"(ex-info \"boom\" (merge {:status-code 400} data))"
|
||||
"(ex-info \"boom\")"]]
|
||||
(testing form
|
||||
(is (empty? (findings agent-message/lint-ex-info form))))))
|
||||
(testing "nothing is reported when the linter is off"
|
||||
(is (empty? (findings agent-message/lint-ex-info "(ex-info \"x\" {:status-code 404})" :off)))))
|
||||
@@ -0,0 +1,25 @@
|
||||
(ns hooks.metabase.app-db.schema-migrations-test.impl-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.app-db.schema-migrations-test.impl]))
|
||||
|
||||
(defn- finding-types!
|
||||
[ns-symb]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/validate-mb-app-db-migrations-test {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(hooks.metabase.app-db.schema-migrations-test.impl/test-migrations!
|
||||
{:ns ns-symb
|
||||
:node (hooks/parse-string (pr-str '(impl/test-migrations ["v64.2026-09-01T00:00:00"] [migrate!] (migrate!))))})
|
||||
(mapv :type @(:findings clj-kondo.impl.utils/*ctx*))))
|
||||
|
||||
(deftest ^:synchronized require-app-db-migrations-test-tag-test
|
||||
(testing "a namespace that runs migration tests must be tagged, so CI shards it with the other migration tests"
|
||||
(is (= [:metabase/validate-mb-app-db-migrations-test]
|
||||
(finding-types! 'metabase.some-migrations-test))))
|
||||
(testing "a tagged namespace is fine"
|
||||
(is (= []
|
||||
(finding-types! (with-meta 'metabase.some-migrations-test {:mb/app-db-migrations-test true}))))))
|
||||
@@ -5,8 +5,15 @@
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.toucan.db-ns :as toucan.db-ns]))
|
||||
|
||||
(defn- confinement-findings
|
||||
"Only the `t2-query-namespace` findings, so these tests are not affected by the query-hygiene
|
||||
linter that shares this hook."
|
||||
[findings]
|
||||
(filter #(= :metabase/t2-query-namespace (:type %)) findings))
|
||||
|
||||
(defn- lint-query-call [form ns-sym & [filename modules]]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/t2-query-namespace {:level :warning}}}
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/t2-query-namespace {:level :warning}
|
||||
:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
@@ -25,11 +32,11 @@
|
||||
:message #".*`t2/select-one`.*"}]
|
||||
(lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.models.card))))
|
||||
(testing "metabase.<module>.db is allowed"
|
||||
(is (empty? (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.db))))
|
||||
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.db)))))
|
||||
(testing "metabase-enterprise.<module>.db is allowed"
|
||||
(is (empty? (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase-enterprise.sandbox.db))))
|
||||
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase-enterprise.sandbox.db)))))
|
||||
(testing "metabase.driver.<driver>.db is allowed for driver modules"
|
||||
(is (empty? (lint-query-call '(t2/select-one :model/Database :id 1) 'metabase.driver.bigquery-cloud-sdk.db))))
|
||||
(is (empty? (confinement-findings (lint-query-call '(t2/select-one :model/Database :id 1) 'metabase.driver.bigquery-cloud-sdk.db)))))
|
||||
(testing "a nested db namespace is not a module db namespace"
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/query {:select [:*]}) 'metabase.queries.models.db))))
|
||||
@@ -71,3 +78,247 @@
|
||||
(lint-query-call '(t2/query {:select [:*]}) 'metabase.metabot.llm.models.db nil modules))))
|
||||
(testing "enterprise modules resolve through the metabase-enterprise root"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase-enterprise.sandbox.db nil modules))))))
|
||||
|
||||
(deftest ^:parallel unmarked-value-in-a-db-namespace-test
|
||||
(testing "a symbol reaching a value slot is flagged"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #"`locale` reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :locale locale]}) 'metabase.foo.db))))
|
||||
(testing "a marked value is not flagged"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale [:auto/param locale]]})
|
||||
'metabase.foo.db))))
|
||||
(testing "a literal cannot carry a request value and is not flagged"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale "de"]}) 'metabase.foo.db))))
|
||||
(testing "a column reference is not a value"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :a.id :b.id]}) 'metabase.foo.db))))
|
||||
(testing "values nested under a boolean connective are reached"
|
||||
(is (=? [{:message #"`b`.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:and [:= :x [:auto/param a]] [:= :y b]]})
|
||||
'metabase.foo.db))))
|
||||
(testing "only db namespaces are linted for unmarked values"
|
||||
(is (empty? (filter #(= :metabase/unsafe-app-db-query (:type %))
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
|
||||
'metabase.foo.models.thing)))))
|
||||
(testing "a test source tree is exempt"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
|
||||
'metabase.foo.db "test/metabase/foo/db_test.clj")))))
|
||||
|
||||
(deftest ^:parallel kv-arg-style-is-not-flagged-test
|
||||
(testing "call style is not linted: a query written as :column value pairs is left alone"
|
||||
;; Extraction to a .sql file is semantic rather than a transcription of the HoneySQL shape, so a
|
||||
;; query map is no closer to that target than kv-args are. Converting also skips Toucan's type
|
||||
;; transforms, which only run in kv-arg position -- that bug shipped twice.
|
||||
(are [form] (empty? (filter #(re-find #"Pass this query a map" (str (:message %)))
|
||||
(lint-query-call form 'metabase.foo.db)))
|
||||
'(t2/select :model/X :locale [:auto/param locale])
|
||||
'(t2/select :model/X :archived false)
|
||||
'(t2/select-one :model/X :id (long id))
|
||||
'(t2/select-one-fn :value :model/Setting :key [:auto/param k])
|
||||
'(t2/select :model/X {:where [:= :locale [:auto/param locale]]})))
|
||||
(testing "an unmarked value in a kv-arg is still flagged -- only the style check went away"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query, :message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X :locale locale) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel conditions-map-values-are-linted-test
|
||||
(testing "a conditions map -- a shape the sweep leaves in place -- is checked"
|
||||
;; `(t2/update! model {:col v} changes)` filters on v, but it is not a `:where` clause, so
|
||||
;; neither the query-map walker nor the kv-arg walker used to see it. A namespace written this
|
||||
;; way passed the lint vacuously.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/update! :model/X {:key k} {:v 1})
|
||||
'(t2/delete! :model/X {:key k})
|
||||
'(t2/update! :model/X {:key [:in ks]} {:v 1})))
|
||||
(testing "a marked or coerced conditions value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/update! :model/X {:key [:auto/param k]} {:v 1})
|
||||
'(t2/update! :model/X {:id (long id)} {:v 1})))
|
||||
(testing "only the map right after the model is conditions -- the changes map is exempt"
|
||||
;; rule 1: a written value must arrive as itself, so it is never flagged.
|
||||
(is (empty? (lint-query-call '(t2/update! :model/X {:id (long id)} {:v written})
|
||||
'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel subquery-values-are-linted-test
|
||||
(testing "a value inside a subquery in a value slot is reached"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*`z`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call
|
||||
'(t2/select :model/X {:where [:in :id {:select [:id] :from [:y] :where [:= :z z]}]})
|
||||
'metabase.foo.db))))
|
||||
(testing "the plain token case still works -- descending must not replace it"
|
||||
(is (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :k v]}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel conditionally-built-clause-test
|
||||
(testing "a value slot inside a conditional form is still reached"
|
||||
;; `value-nodes` used to stop at a list node, so anything a `when`/`if`/`cond->` built was
|
||||
;; invisible -- a false negative in a security lint. permissions/db.clj and collections/db.clj
|
||||
;; both build clauses this way.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:and (when k [:= :key k])]})))
|
||||
(testing "both branches of an if are reached"
|
||||
(is (= 2 (count (lint-query-call
|
||||
'(t2/select :model/X {:where [:and (if flag [:= :key k] [:= :key other-k])]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a marked value inside a conditional form is not flagged"
|
||||
(is (empty? (lint-query-call
|
||||
'(t2/select :model/X {:where [:and (when k [:= :key [:auto/param k]])]})
|
||||
'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel kv-arg-values-are-linted-test
|
||||
(testing "an unmarked value in a kv-arg pair is flagged, so dropping the style check left no gap"
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #".*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :locale locale)
|
||||
'(t2/select-one :model/X :id id)
|
||||
;; the pairs do not start at a fixed offset -- :value comes before the model here
|
||||
'(t2/select-one-fn :value :model/Setting :key k)
|
||||
;; a value inside an operator form is reached, rather than the form being treated as a value
|
||||
'(t2/select :model/X :id [:in ids])))
|
||||
(testing "a marked or coerced kv-arg value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :locale [:auto/param locale])
|
||||
'(t2/select-one :model/X :id (long id))
|
||||
'(t2/select :model/X :id [:in (mapv long ids)])
|
||||
'(t2/select :model/X :id [:in [:auto/param ids]])))
|
||||
(testing "a literal kv-arg value is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :archived false)
|
||||
'(t2/select :model/X :status "pending")
|
||||
'(t2/select :model/X :engine :appdb)))
|
||||
(testing "one unmarked value per pair is reported"
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X :locale locale :msgid msgid)
|
||||
'metabase.foo.db)))))
|
||||
(testing "a call mixing kv-args with a trailing query map reports each value exactly once"
|
||||
;; The two walkers run over the same arguments, so this pins that they do not double-report.
|
||||
(is (= 2 (count (lint-query-call
|
||||
'(t2/select :model/X :locale locale {:where [:= :other other]})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel write-calls-are-not-linted-for-values-test
|
||||
(testing "an insert's values are written, not filtered on, so they are not flagged"
|
||||
(are [form] (empty? (filter #(= :metabase/unsafe-app-db-query (:type %))
|
||||
(lint-query-call form 'metabase.foo.db)))
|
||||
'(t2/insert! :model/X :key k :value v)
|
||||
'(t2/insert-returning-instances! :model/X :key k :value v)))
|
||||
(testing "a select's values are still flagged"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query}]
|
||||
(lint-query-call '(t2/select :model/X :key k) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel operator-arity-test
|
||||
(testing "a value operator with an unexpected arity still has its args examined"
|
||||
(is (seq (lint-query-call '(t2/select :model/X {:where [:= :a b c]}) 'metabase.foo.db)))
|
||||
(is (seq (lint-query-call '(t2/select :model/X {:where [:between :a lo hi]}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel operator-coverage-test
|
||||
(testing "comparison operators HoneySQL accepts as aliases for not= are checked"
|
||||
;; The set used to list only `not=`, so `[:!= :col v]` -- live in collections/db.clj -- was
|
||||
;; silently unchecked even though HoneySQL binds it as `<>`.
|
||||
(are [form] (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:!= :k v]})
|
||||
'(t2/select :model/X {:where [:<> :k v]})
|
||||
'(t2/select :model/X {:where [:is-distinct-from :k v]})))
|
||||
(testing "a two-value operator reports both values"
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X {:where [:not-between :k lo hi]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a value wrapped in a function-call form is reached"
|
||||
;; `[:lower v]` compiles to `LOWER(?)`, so `v` is a real bind slot.
|
||||
(are [form] (seq (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:= :k [:lower v]]})
|
||||
'(t2/select :model/X {:where [:= :k [:cast v :text]]})
|
||||
'(t2/select :model/X :k [:lower v])))
|
||||
(testing "values in a literal collection are reached"
|
||||
;; `[:in :k [a b]]` binds each element -- `IN (?, ?)`.
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X {:where [:in :k [a b]]})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel no-duplicate-findings-test
|
||||
(testing "a multi-arg operator in a kv-arg reports each value exactly once"
|
||||
;; value-nodes misreads `[:between lo hi]` as a clause (lo as column), while
|
||||
;; kv-arg-value-nodes reads it correctly, so the walkers overlap on `hi`. Deduping by source
|
||||
;; position makes that harmless.
|
||||
(is (= 2 (count (lint-query-call '(t2/select :model/X :d [:between lo hi]) 'metabase.foo.db))))
|
||||
(is (= 2 (count (lint-query-call '(t2/delete! :model/X {:d [:between lo hi]})
|
||||
'metabase.foo.db)))))
|
||||
(testing "a query map passed where conditions go reports each value once"
|
||||
;; `{:where ...}` is a query map that value-nodes already walks; treating it as conditions too
|
||||
;; reported everything twice. Live in mcp/db.clj, metabot/db.clj, testing_api/db.clj.
|
||||
(is (= 1 (count (lint-query-call '(t2/delete! :model/X {:where [:= :k k]}) 'metabase.foo.db))))
|
||||
(is (= 2 (count (lint-query-call '(t2/update! :model/X {:where [:and [:= :a a] [:= :b b]]} {:v 1})
|
||||
'metabase.foo.db))))))
|
||||
|
||||
(deftest ^:parallel two-arity-update-changes-map-test
|
||||
(testing "update!'s lone trailing map is CHANGES, not conditions, so its values are not flagged"
|
||||
;; Toucan's arglist ends in the changes map. remote_sync/db.clj's mark-all-rsos-synced! is this
|
||||
;; shape; flagging `written` would ask for a marker on a written value, which the column's
|
||||
;; `:in` transform stores as data.
|
||||
(is (empty? (lint-query-call '(t2/update! :model/X {:v written}) 'metabase.foo.db))))
|
||||
(testing "delete! has no changes map, so its lone map is always conditions"
|
||||
(is (=? [{:message #"`k`.*"}]
|
||||
(lint-query-call '(t2/delete! :model/X {:k k}) 'metabase.foo.db))))
|
||||
(testing "update!'s FIRST map is still conditions when a changes map follows"
|
||||
(is (=? [{:message #"`k`.*"}]
|
||||
(lint-query-call '(t2/update! :model/X {:k k} {:v 1}) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel identifier-clauses-are-not-value-slots-test
|
||||
(testing "a symbol naming a column or a table is not reported"
|
||||
;; `value-guard` refuses a marker in these clauses, so reporting one asks for a fix that throws
|
||||
;; at compile. data_studio/db.clj, task_history/db.clj and permissions/db.clj are written this
|
||||
;; way. `:select` is the exception: index 0 of its entries is an expression, so it has its own
|
||||
;; test in expression-entry-clauses-test.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [:*] :from [[union-query :subquery]]})
|
||||
'(t2/query {:select [:*] :from [:t] :order-by [[:sort_key sort-direction]]})
|
||||
'(t2/query {:select [:*] :from [:t] :group-by [group-column]})))
|
||||
(testing "a value nested inside an identifier clause is still reported"
|
||||
(are [form] (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
;; a computed projection is a real comparison
|
||||
'(t2/query {:select [[[:= :engine v] :is_match]] :from [:t]})
|
||||
;; so is a CASE sort key
|
||||
'(t2/query {:select [:*] :from [:t] :order-by [[[:case [:= :a v] 1 :else 2] :asc]]})
|
||||
;; a subquery carries its own where clause
|
||||
'(t2/query {:select [:*] :from [[{:select [:*] :from [:u] :where [:= :k v]} :s]]}))))
|
||||
|
||||
(deftest ^:parallel expression-entry-clauses-test
|
||||
(testing "index 0 of an entry is a value slot, because HoneySQL binds a parameter there"
|
||||
;; `{:select [[[:auto/param "a"] :k]]}` compiles to `SELECT ? AS "K"`, so `value-guard` accepts
|
||||
;; a marker there. Treating the whole clause as names would hide a request value.
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #"`v`.*reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[v :label]] :from [:t]})
|
||||
'(t2/query {:select-distinct [[v :label]] :from [:t]})
|
||||
'(t2/query {:insert-into [:t] :returning [[v :label]]})))
|
||||
(testing "the alias, a bare entry and a table entry are names"
|
||||
;; `value-guard` refuses a marker in each of these, so asking for one would break the query.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[:key alias-sym]] :from [:t]})
|
||||
'(t2/query {:select [cols] :from [:t]})
|
||||
'(t2/query {:select [:*] :from [[tbl :x]]})))
|
||||
(testing "a computed projection and a subquery entry still reach their values"
|
||||
(are [form] (=? [{:message #"`v`.*"}] (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query {:select [[[:= :engine v] :is_match]] :from [:t]})
|
||||
'(t2/query {:select [:*] :from [[{:select [:*] :from [:u] :where [:= :k v]} :s]]}))))
|
||||
|
||||
(deftest ^:parallel clause-context-survives-assoc-test
|
||||
(testing "a clause key in an assoc call gives its value the clause context back"
|
||||
;; task_history/db.clj:174 and five other namespaces build `:order-by` this way. Without this
|
||||
;; the lint asks for a marker on a column name and on a sort direction, and the fix the message
|
||||
;; names loses the ordering with no error.
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/query (assoc q :order-by [[sort-column sort-direction] [:id :desc]]))
|
||||
'(t2/query (cond-> q flag (assoc :order-by [[sort-column sort-direction]])))
|
||||
'(t2/query (assoc q :group-by [group-column]))
|
||||
'(t2/query (assoc q :select [[:key alias-sym]]))))
|
||||
(testing "a value clause built the same way is still reported"
|
||||
(is (=? [{:message #"`v`.*"}]
|
||||
(lint-query-call '(t2/query (assoc q :where [:= :k v])) 'metabase.foo.db))))
|
||||
(testing "an expression entry built the same way is still reported"
|
||||
(is (=? [{:message #"`v`.*"}]
|
||||
(lint-query-call '(t2/query (assoc q :select [[v :label]])) 'metabase.foo.db)))))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// imported, vs the *allowed* edges in module-boundaries.mjs). Consumed by
|
||||
// .github/scripts/create-test-plan.ts.
|
||||
//
|
||||
// bunx depcruise frontend/src enterprise/frontend/src \
|
||||
// bunx depcruise frontend/src enterprise/frontend/src .storybook/preview.tsx \
|
||||
// --config .dependency-cruiser.cjs --output-type json > dependency-graph.json
|
||||
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
@@ -10,10 +10,13 @@ module.exports = {
|
||||
options: {
|
||||
// Keep only edges between our own source files (drops node_modules etc.),
|
||||
// which keeps the JSON small.
|
||||
includeOnly: "^(frontend/src|enterprise/frontend/src)/",
|
||||
includeOnly: "^(frontend/src|enterprise/frontend/src|\\.storybook)/",
|
||||
|
||||
doNotFollow: { path: "node_modules" },
|
||||
|
||||
// No rules here, so the cycle and orphan analysis has no consumer and only costs time.
|
||||
skipAnalysisNotInRules: true,
|
||||
|
||||
// Resolve the `"*": ["./frontend/src/*", ...]` aliases so bare imports like
|
||||
// `metabase/lib/foo` map back to a real file.
|
||||
tsConfig: { fileName: "tsconfig.json" },
|
||||
|
||||
46
.env.example
@@ -121,6 +121,9 @@ MB_EDITION="ee"
|
||||
# Frontend dev server port (for hot reload). Must match the port used by `yarn build-hot`.
|
||||
# MB_FRONTEND_DEV_PORT=8080
|
||||
|
||||
# Frontend dev server bind address. Defaults to 127.0.0.1; set 0.0.0.0 to reach it from other devices.
|
||||
# MB_FRONTEND_DEV_HOST=127.0.0.1
|
||||
|
||||
# 3.2 E2E Local Runner Setup
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
@@ -161,3 +164,46 @@ TZ="US/Pacific"
|
||||
# DO NOT USE Docker Desktop. Use Orbstack instead. Metabase will fail to connect
|
||||
# to the dockerized Mongo QA database otherwise.
|
||||
EXPERIMENTAL_DOCKER_DESKTOP_FORCE_QEMU=1
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 4. BACKEND DRIVER TESTS
|
||||
# ==============================================================================
|
||||
#
|
||||
# Connection details for running backend tests against a driver, e.g.
|
||||
# ./bin/test-agent --drivers=snowflake :only '[metabase.foo-test]'
|
||||
|
||||
# 4.1 BigQuery
|
||||
# ------------------------------------------------------------------------------
|
||||
# Service account with BigQuery access in the project. The test user creates and drops the test-data datasets itself.
|
||||
# https://cloud.google.com/iam/docs/keys-create-delete
|
||||
|
||||
# MB_BIGQUERY_CLOUD_SDK_TEST_PROJECT_ID="<project-id>"
|
||||
# MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON='{"type": "service_account", ...}'
|
||||
# Only for database routing tests
|
||||
# MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING='{"type": "service_account", ...}'
|
||||
|
||||
# 4.2 Redshift
|
||||
# ------------------------------------------------------------------------------
|
||||
# Test data is loaded into a unique schema per test run.
|
||||
|
||||
# MB_REDSHIFT_TEST_HOST="<cluster>.<id>.<region>.redshift.amazonaws.com"
|
||||
# Comma-separated hosts to pick one at random from, instead of MB_REDSHIFT_TEST_HOST
|
||||
# MB_REDSHIFT_TEST_HOSTS=""
|
||||
# MB_REDSHIFT_TEST_PORT=5439
|
||||
# MB_REDSHIFT_TEST_DB="testdb"
|
||||
# MB_REDSHIFT_TEST_USER="metabase_ci"
|
||||
# MB_REDSHIFT_TEST_PASSWORD="<password>"
|
||||
# Only for database routing tests
|
||||
# MB_REDSHIFT_TEST_DB_ROUTING="dev"
|
||||
|
||||
# 4.3 Snowflake
|
||||
# ------------------------------------------------------------------------------
|
||||
# Key-pair authentication. The test user creates and drops the test-data databases itself.
|
||||
# https://docs.snowflake.com/en/user-guide/key-pair-auth
|
||||
|
||||
# MB_SNOWFLAKE_TEST_ACCOUNT="<orgname>-<accountname>"
|
||||
# MB_SNOWFLAKE_TEST_USER="<username>"
|
||||
# MB_SNOWFLAKE_TEST_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
# MB_SNOWFLAKE_TEST_WAREHOUSE="<warehouse>"
|
||||
# MB_SNOWFLAKE_TEST_ADDITIONAL_OPTIONS=""
|
||||
|
||||
14
.github/actions/collect-profile/action.yml
vendored
@@ -22,6 +22,9 @@ runs:
|
||||
# `awk 'NR<=25'` rather than `head -25`: the runner ignores SIGPIPE, so `head` exiting early
|
||||
# leaves `sort` with an EPIPE write error and `pipefail` fails the step. `|| true` covers `grep`
|
||||
# finding no junit files.
|
||||
#
|
||||
# `-r --include` rather than a `target/junit/*.xml` glob: a multi-JVM run nests one directory
|
||||
# per partition under `target/junit`, and a non-recursive glob matches none of it.
|
||||
- name: Summarize test wall times
|
||||
if: ${{ inputs.profiled == 'true' }}
|
||||
continue-on-error: true
|
||||
@@ -30,13 +33,13 @@ runs:
|
||||
{
|
||||
echo "### Slowest test namespaces (seconds)"
|
||||
echo '```'
|
||||
grep -h '<testsuite' target/junit/*.xml 2>/dev/null \
|
||||
grep -rh --include='*.xml' '<testsuite' target/junit 2>/dev/null \
|
||||
| sed -E 's|.*<testsuite name="([^"]*)" time="([^"]*)".*|\2 \1|' \
|
||||
| sort -rn | awk 'NR<=25' || true
|
||||
echo '```'
|
||||
echo "### Slowest tests (seconds)"
|
||||
echo '```'
|
||||
grep -h '<testcase' target/junit/*.xml 2>/dev/null \
|
||||
grep -rh --include='*.xml' '<testcase' target/junit 2>/dev/null \
|
||||
| sed -E 's|.*classname="([^"]*)" name="([^"]*)" time="([^"]*)".*|\3 \1/\2|' \
|
||||
| sort -rn | awk 'NR<=25' || true
|
||||
echo '```'
|
||||
@@ -53,9 +56,12 @@ runs:
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
# Globs, because a multi-JVM run nests its junit XML one directory per partition and
|
||||
# names each log `test-log-p<index>.json`. The flamegraphs stay flat, with that same
|
||||
# `p<index>-` prefix.
|
||||
path: |
|
||||
target/clj-async-profiler/results/*flamegraph.html
|
||||
target/junit/*.xml
|
||||
logs/test-log.json
|
||||
target/junit/**/*.xml
|
||||
logs/test-log*.json
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
20
.github/actions/create-backport/action.yml
vendored
@@ -85,17 +85,27 @@ runs:
|
||||
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Approve and Merge the PR
|
||||
# Approving takes a different token than the one that opened the PR: GitHub won't let a token
|
||||
# approve its own pull request.
|
||||
- name: Approve the backport PR
|
||||
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
|
||||
run: |
|
||||
gh pr review --approve "$PR_NUMBER"
|
||||
gh pr merge --squash --auto "$PR_NUMBER"
|
||||
run: gh pr review --approve "$PR_NUMBER"
|
||||
shell: bash
|
||||
env:
|
||||
# uses a different token than the token that created the PR so we can approve and merge it
|
||||
GH_TOKEN: ${{ inputs.github-token-2 }}
|
||||
PR_NUMBER: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
|
||||
|
||||
# Merging, though, has to use `github-token` rather than the `GITHUB_TOKEN` that approves: GitHub
|
||||
# starts no workflow run for a push made with `GITHUB_TOKEN`, so an auto-merge enabled with it
|
||||
# lands the backport on the release branch with no CI at all.
|
||||
- name: Merge the backport PR
|
||||
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
|
||||
run: gh pr merge --squash --auto "$PR_NUMBER"
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ inputs.github-token }}
|
||||
PR_NUMBER: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
|
||||
|
||||
- uses: ./.github/actions/notify-pull-request
|
||||
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'true' }}
|
||||
with:
|
||||
|
||||
47
.github/actions/create-test-plan/action.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Create test plan
|
||||
description: Cruise the frontend import graph and compute the affected-tests plan.
|
||||
description: Compute the affected-tests plan for the changed files.
|
||||
|
||||
inputs:
|
||||
changed-files:
|
||||
@@ -28,21 +28,15 @@ outputs:
|
||||
stats:
|
||||
description: FE Affected Tests stats row (JSON).
|
||||
value: ${{ steps.compute.outputs.stats }}
|
||||
fe_unit_specs_to_run:
|
||||
description: Unit specs to run (JSON array).
|
||||
value: ${{ steps.compute.outputs.fe_unit_specs_to_run }}
|
||||
loki_stories_to_run:
|
||||
description: Loki stories to run (JSON array).
|
||||
value: ${{ steps.compute.outputs.loki_stories_to_run }}
|
||||
e2e_specs_to_run:
|
||||
description: E2E specs to run (JSON array).
|
||||
value: ${{ steps.compute.outputs.e2e_specs_to_run }}
|
||||
artifact-id:
|
||||
description: Artifact containing test-plan.json for this run.
|
||||
value: ${{ steps.upload.outputs.artifact-id }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prepare Node + Bun
|
||||
uses: ./.github/actions/prepare-node-bun
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Resolve closest-ancestor e2e coverage manifest
|
||||
id: manifest
|
||||
uses: actions/github-script@v9
|
||||
@@ -61,16 +55,6 @@ runs:
|
||||
headSha: context.payload.pull_request?.head?.sha ?? context.sha,
|
||||
});
|
||||
core.setOutput("path", result?.path ?? "");
|
||||
- name: Build dependency graph
|
||||
shell: bash
|
||||
run: |
|
||||
# Quiet on success; surface output only if install fails.
|
||||
bun install --frozen-lockfile > /tmp/bun-install.log 2>&1 \
|
||||
|| { echo "bun install issues (continuing):"; tail -20 /tmp/bun-install.log; }
|
||||
bunx depcruise frontend/src enterprise/frontend/src \
|
||||
--config .dependency-cruiser.cjs \
|
||||
--output-type json > dependency-graph.json || true
|
||||
|
||||
- name: Create test plan
|
||||
id: compute
|
||||
shell: bash
|
||||
@@ -82,15 +66,20 @@ runs:
|
||||
LOKI_INFRA_TOUCHED: ${{ inputs.loki-infra-touched }}
|
||||
E2E_INFRA_TOUCHED: ${{ inputs.e2e-infra-touched }}
|
||||
SHARED_SOURCES_TOUCHED: ${{ inputs.shared-sources-touched }}
|
||||
DEP_GRAPH_JSON: dependency-graph.json
|
||||
E2E_SPEC_MANIFEST: ${{ steps.manifest.outputs.path }}
|
||||
run: |
|
||||
# stdout is the plan JSON; the planner's own info logs go to stderr.
|
||||
# The planner writes this step's GITHUB_OUTPUT entries.
|
||||
bun .github/scripts/create-test-plan.ts > test-plan.json
|
||||
echo "stats=$(jq -c '.stats' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "fe_unit_specs_to_run=$(jq -c '.fe_unit_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "loki_stories_to_run=$(jq -c '.loki_stories_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs_to_run=$(jq -c '.e2e_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload test plan
|
||||
id: upload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-plan-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: test-plan.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Report test plan
|
||||
shell: bash
|
||||
@@ -110,6 +99,10 @@ runs:
|
||||
echo
|
||||
echo "Changed: $(jq '.stats.fe_files_changed' "$plan") FE / $(jq '.stats.be_files_changed' "$plan") BE file(s) · $(jq '.stats.fe_modules_affected_usage' "$plan") FE module(s) affected"
|
||||
echo
|
||||
if [ "$(jq '.stats.loki_preview_touched' "$plan")" = "true" ]; then
|
||||
echo "**Loki: full run**: a file the Storybook preview imports changed."
|
||||
echo
|
||||
fi
|
||||
e2e_run=$(jq '.stats.e2e_specs_to_run_usage' "$plan")
|
||||
e2e_all=$(jq '.stats.e2e_specs_all' "$plan")
|
||||
if [ "$e2e_run" -eq "$e2e_all" ]; then
|
||||
|
||||
67
.github/actions/get-docker-info/action.yml
vendored
@@ -2,12 +2,16 @@ name: Get Docker Info
|
||||
description: Determines the registry, repository, and tag a container image is pushed to.
|
||||
|
||||
inputs:
|
||||
registry:
|
||||
description: Registry the image is pushed to, either "docker" or "ecr".
|
||||
required: false
|
||||
default: docker
|
||||
repo:
|
||||
description: Registry and repo, e.g. "metabase/metabase". Overrides branch derived repo.
|
||||
description: Repository name, e.g. "metabase-enterprise". Overrides a branch derived repo.
|
||||
required: false
|
||||
default: ''
|
||||
tag:
|
||||
description: Docker tag, e.g. v1.87.2. Overrides a branch derived tag.
|
||||
description: Image tag, e.g. v1.87.2. Overrides a branch derived tag.
|
||||
required: false
|
||||
default: ''
|
||||
branch:
|
||||
@@ -18,14 +22,22 @@ inputs:
|
||||
description: Edition (ee or oss)
|
||||
required: false
|
||||
default: ee
|
||||
owner:
|
||||
description: Repository owner (defaults to current repo owner)
|
||||
aws-account-id:
|
||||
description: AWS account id hosting the ECR registry. Required for the ecr registry.
|
||||
required: false
|
||||
default: ${{ github.repository_owner }}
|
||||
default: ''
|
||||
aws-region:
|
||||
description: Region of the ECR registry.
|
||||
required: false
|
||||
default: us-east-1
|
||||
dockerhub-owner:
|
||||
description: DockerHub owner.
|
||||
required: false
|
||||
default: metabase
|
||||
|
||||
outputs:
|
||||
repo:
|
||||
description: Docker repository name
|
||||
description: Docker repository, including the registry host (ecr) or the owner (docker)
|
||||
value: ${{ steps.docker-info.outputs.repo }}
|
||||
tag:
|
||||
description: Docker tag
|
||||
@@ -40,28 +52,49 @@ runs:
|
||||
- name: Determine Docker info
|
||||
id: docker-info
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
REGISTRY: ${{ inputs.registry }}
|
||||
REPO: ${{ inputs.repo }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
BRANCH: ${{ inputs.branch }}
|
||||
EDITION: ${{ inputs.edition }}
|
||||
AWS_ACCOUNT_ID: ${{ inputs.aws-account-id }}
|
||||
AWS_REGION: ${{ inputs.aws-region }}
|
||||
DOCKERHUB_OWNER: ${{ inputs.dockerhub-owner }}
|
||||
with:
|
||||
script: | # js
|
||||
const branch = '${{ inputs.branch }}';
|
||||
const edition = '${{ inputs.edition }}';
|
||||
const owner = '${{ inputs.owner }}';
|
||||
const { REGISTRY, OWNER, BRANCH, EDITION, AWS_ACCOUNT_ID, AWS_REGION, DOCKERHUB_OWNER } = process.env;
|
||||
|
||||
let tag = '${{ inputs.tag}}';
|
||||
let tag = process.env.TAG;
|
||||
if (!tag) {
|
||||
tag = branch === 'master' ? 'latest' : `${branch.replaceAll('/', '-')}-${edition}`;
|
||||
tag = BRANCH === 'master' ? 'latest' : `${BRANCH.replaceAll('/', '-')}-${EDITION}`;
|
||||
}
|
||||
|
||||
let repo = '${{ inputs.repo }}';
|
||||
if (!repo) {
|
||||
if (branch === 'master') {
|
||||
repo = edition === 'ee' ? `${owner}/metabase-enterprise-head` : `${owner}/metabase-head`;
|
||||
let name = process.env.REPO;
|
||||
if (!name) {
|
||||
if (BRANCH === 'master') {
|
||||
name = EDITION === 'ee' ? 'metabase-enterprise-head' : 'metabase-head';
|
||||
} else {
|
||||
repo = `${owner}/metabase-dev`;
|
||||
name = 'metabase-dev';
|
||||
}
|
||||
}
|
||||
|
||||
let repo;
|
||||
if (REGISTRY === 'ecr') {
|
||||
if (!AWS_ACCOUNT_ID) {
|
||||
core.setFailed('Pushing to AWS ECR requires aws-account-id to be set.');
|
||||
return;
|
||||
}
|
||||
repo = `${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${name}`;
|
||||
} else if (REGISTRY === 'docker') {
|
||||
repo = `${DOCKERHUB_OWNER}/${name}`;
|
||||
} else {
|
||||
core.setFailed(`Unknown registry "${REGISTRY}", expected "docker" or "ecr".`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('repo', repo);
|
||||
core.setOutput('tag', tag);
|
||||
core.setOutput('full_image', `${repo}:${tag}`);
|
||||
|
||||
console.log(`Resolved: repo = ${repo}, tag = ${tag}`);
|
||||
console.log(`Resolved: registry = ${REGISTRY}, repo = ${repo}, tag = ${tag}`);
|
||||
|
||||
39
.github/actions/prepare-test-selection/action.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Prepare test selection
|
||||
description: Download this run's test plan and write the suite's selected test paths.
|
||||
|
||||
inputs:
|
||||
suite:
|
||||
description: The suite to select tests for (unit, loki or e2e).
|
||||
required: true
|
||||
test-plan-artifact-id:
|
||||
description: ID of this run's test-plan.json artifact.
|
||||
required: true
|
||||
require-download:
|
||||
description: Fail the step when the test plan download fails, instead of running the full suite.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
outputs:
|
||||
paths-file:
|
||||
description: JSON file listing the selected test paths, or '' when the suite runs in full.
|
||||
value: ${{ steps.selection.outputs.paths-file }}
|
||||
selection:
|
||||
description: full, narrowed or empty, or '' when the plan is unavailable and the suite runs in full.
|
||||
value: ${{ steps.selection.outputs.selection }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download test plan
|
||||
id: test-plan
|
||||
continue-on-error: ${{ inputs.require-download != 'true' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
artifact-ids: ${{ inputs.test-plan-artifact-id }}
|
||||
path: ${{ runner.temp }}/test-plan
|
||||
- name: Prepare test selection
|
||||
id: selection
|
||||
shell: bash
|
||||
env:
|
||||
PLAN_DOWNLOADED: ${{ steps.test-plan.outcome }}
|
||||
run: bun .github/scripts/prepare-test-selection.ts "${{ inputs.suite }}"
|
||||
10
.github/actions/test-driver/action.yml
vendored
@@ -130,6 +130,7 @@ runs:
|
||||
if [ "$PROFILE_TESTS" = "true" ]; then
|
||||
echo "Profiling this run; a flamegraph will be attached to the job."
|
||||
echo "profiled=true" >> "$GITHUB_OUTPUT"
|
||||
ALIASES="$ALIASES:profile-tests"
|
||||
BASE="$BASE:profile-tests"
|
||||
fi
|
||||
# An explicit selection wins over the rerun narrowing below: it was asked for by hand, on this
|
||||
@@ -149,6 +150,15 @@ runs:
|
||||
shell: bash
|
||||
id: run-test
|
||||
env:
|
||||
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. Inside
|
||||
# a called workflow `github.event` is the caller's payload, so these resolve here rather than
|
||||
# in each calling workflow. On a normal CI run there are no dispatch inputs, so these render
|
||||
# `false` and empty and the job's own selection stands.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job
|
||||
# runs the whole list.
|
||||
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
|
||||
ONLY_TESTS: ${{ github.event.inputs.tests }}
|
||||
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
|
||||
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}
|
||||
TEST_JVMS_PER_RUNNER: ${{ inputs.jvms-per-runner }}
|
||||
|
||||
49
.github/file-paths.yaml
vendored
@@ -63,12 +63,10 @@ frontend_specs: &frontend_specs
|
||||
- "frontend/**/tests/**"
|
||||
- "frontend/**/*.unit.*"
|
||||
- "enterprise/frontend/**/*.unit.*"
|
||||
- ".github/**/*.unit.spec.*"
|
||||
# file-paths.unit.spec.ts asserts what this file's own patterns match, so a change to it has to
|
||||
# run the specs that guard it.
|
||||
- ".github/file-paths.yaml"
|
||||
- "jest.config.js"
|
||||
- "jest.tz.unit.conf.json"
|
||||
- "jest.base.conf.js"
|
||||
- "jest.esm-packages.js"
|
||||
- "jest.tz.unit.conf.js"
|
||||
|
||||
release_source: &release_sources
|
||||
- "release/**"
|
||||
@@ -80,6 +78,7 @@ frontend_all: &frontend_all
|
||||
|
||||
frontend_loki_ci: &frontend_loki_ci
|
||||
- ".github/workflows/loki.yml"
|
||||
- "loki.config.js"
|
||||
- ".loki/**"
|
||||
- ".storybook/**"
|
||||
|
||||
@@ -87,14 +86,52 @@ frontend_unit_infra: &frontend_unit_infra
|
||||
- *frontend_configs
|
||||
- *frontend_ci
|
||||
- "jest.config.js"
|
||||
- "jest.tz.unit.conf.json"
|
||||
- "jest.base.conf.js"
|
||||
- "jest.esm-packages.js"
|
||||
- "jest.tz.unit.conf.js"
|
||||
- "frontend/test/**"
|
||||
- "frontend/**/__mocks__/**"
|
||||
- "frontend/src/embedding-sdk-shared/jest/**"
|
||||
|
||||
frontend_loki_infra: &frontend_loki_infra
|
||||
- *frontend_configs
|
||||
- *shared_ci
|
||||
- *frontend_loki_ci
|
||||
- "frontend/build/shared/**"
|
||||
- "frontend/test/__support__/**"
|
||||
- "patches/**"
|
||||
# dependency-cruiser doesn't follow CSS @import, so global styles must run every story.
|
||||
- "frontend/src/metabase/css/**/*.css"
|
||||
# Storybook serves resources/frontend_client as static files, and the stories bundle the app's SVGs.
|
||||
- "resources/frontend_client/**"
|
||||
- "frontend/**/*.svg"
|
||||
- "enterprise/frontend/**/*.svg"
|
||||
|
||||
frontend_loki_all:
|
||||
- *frontend_sources
|
||||
- *frontend_loki_infra
|
||||
|
||||
# Everything the ci-scripts jest project runs or depends on.
|
||||
# test-unit ignores that project, so this filter is the only thing that reruns it.
|
||||
ci_scripts:
|
||||
- *frontend_ci
|
||||
- ".nvmrc"
|
||||
# file-paths.unit.spec.ts asserts what this file's own patterns match, so a change to it has to
|
||||
# run the specs that guard it.
|
||||
- ".github/file-paths.yaml"
|
||||
- ".github/scripts/**"
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/loki.yml"
|
||||
- ".storybook/story-files.cjs"
|
||||
- "frontend/lint/module-boundaries.mjs"
|
||||
- "frontend/lint/shared-tiers.mjs"
|
||||
- "frontend/test/jest-test-paths-filter.ts"
|
||||
- ".dependency-cruiser.cjs"
|
||||
- "jest.config.js"
|
||||
- "jest.base.conf.js"
|
||||
- "jest.esm-packages.js"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
|
||||
# e2e harness/support code (commands, support file, runner). Not in the
|
||||
# instrumented bundle, so it maps to no covered module — a change here forces a
|
||||
|
||||
22
.github/scripts/affected-modules.ts
vendored
@@ -105,6 +105,28 @@ export function getAffectedFiles(
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** The file plus every file it transitively imports. */
|
||||
export function getTransitiveDependencies(
|
||||
fileDependencies: FileDependency[],
|
||||
file: string,
|
||||
): Set<string> {
|
||||
const dependenciesOf = new Map(
|
||||
fileDependencies.map(({ source, dependencies }) => [source, dependencies]),
|
||||
);
|
||||
const reached = new Set([file]);
|
||||
const queue = [file];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const dependency of dependenciesOf.get(current) ?? []) {
|
||||
if (!reached.has(dependency)) {
|
||||
reached.add(dependency);
|
||||
queue.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
export type ModuleGraph = {
|
||||
nodes: ModuleNode[];
|
||||
/**
|
||||
|
||||
26
.github/scripts/affected-modules.unit.spec.ts
vendored
@@ -6,6 +6,7 @@ import {
|
||||
getAffectedFiles,
|
||||
getAffectedModules,
|
||||
getChangedModules,
|
||||
getTransitiveDependencies,
|
||||
mapFileToModule,
|
||||
parseCruiseModules,
|
||||
} from "./affected-modules";
|
||||
@@ -123,6 +124,31 @@ describe("affected modules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransitiveDependencies", () => {
|
||||
it("should return the file and everything it transitively imports", () => {
|
||||
expect(
|
||||
[...getTransitiveDependencies(FILE_DEPS, "src/app.js")].sort(),
|
||||
).toEqual(["src/app.js", "src/foo/foo.tsx", "src/utils/colors.ts"]);
|
||||
});
|
||||
|
||||
it("should return only the file when it imports nothing", () => {
|
||||
expect([
|
||||
...getTransitiveDependencies(FILE_DEPS, "src/utils/colors.ts"),
|
||||
]).toEqual(["src/utils/colors.ts"]);
|
||||
});
|
||||
|
||||
it("should stop at an import cycle", () => {
|
||||
const cyclic = [
|
||||
{ source: "src/a.ts", dependencies: ["src/b.ts"] },
|
||||
{ source: "src/b.ts", dependencies: ["src/a.ts"] },
|
||||
];
|
||||
|
||||
expect([...getTransitiveDependencies(cyclic, "src/a.ts")].sort()).toEqual(
|
||||
["src/a.ts", "src/b.ts"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUsageModuleGraph + getAffectedModules", () => {
|
||||
const usageGraph = buildUsageModuleGraph(ELEMENTS, FILE_DEPS);
|
||||
|
||||
|
||||
45
.github/scripts/affected-tests.ts
vendored
@@ -11,6 +11,7 @@ import {
|
||||
buildUsageModuleGraph,
|
||||
getAffectedModules,
|
||||
getChangedModules,
|
||||
getTransitiveDependencies,
|
||||
mapFileToModule,
|
||||
} from "./affected-modules";
|
||||
|
||||
@@ -21,6 +22,7 @@ export type TestPlanStats = {
|
||||
be_files_total: number;
|
||||
unit_infra_touched: boolean;
|
||||
loki_infra_touched: boolean;
|
||||
loki_preview_touched: boolean;
|
||||
shared_sources_touched: boolean;
|
||||
fe_modules_total: number;
|
||||
fe_modules_changed: number;
|
||||
@@ -48,9 +50,12 @@ export type CreateTestPlanInput = {
|
||||
elements: ModuleDef[];
|
||||
rules: Rule[];
|
||||
changedFiles: string[];
|
||||
// Parsed dependency-cruiser edges, or null to fall back to the rules graph.
|
||||
fileDependencies: FileDependency[] | null;
|
||||
// Loads the dependency-cruiser edges, called only when some suite can be narrowed.
|
||||
// Null falls back to the rules graph.
|
||||
loadFileDependencies: () => FileDependency[] | null;
|
||||
testFilesBySuite: { unit: string[]; loki: string[]; e2e: string[] };
|
||||
// Storybook's preview file, whose imports wrap every story.
|
||||
lokiPreviewFile: string;
|
||||
e2eSpecFiles: Record<string, string[]> | null;
|
||||
unitInfraTouched: boolean;
|
||||
lokiInfraTouched: boolean;
|
||||
@@ -63,6 +68,7 @@ export type CreateTestPlanInput = {
|
||||
};
|
||||
|
||||
// Tests whose owning module is affected.
|
||||
// A spec outside the module tree always runs, because the graph cannot scope it.
|
||||
export function filterAffectedTests(
|
||||
nodes: ModuleNode[],
|
||||
affected: Set<string>,
|
||||
@@ -70,7 +76,7 @@ export function filterAffectedTests(
|
||||
): string[] {
|
||||
return testFiles.filter((file) => {
|
||||
const module = mapFileToModule(nodes, file);
|
||||
return module !== null && affected.has(module);
|
||||
return module === null || affected.has(module);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,8 +136,9 @@ export function createTestPlan({
|
||||
elements,
|
||||
rules,
|
||||
changedFiles,
|
||||
fileDependencies,
|
||||
loadFileDependencies,
|
||||
testFilesBySuite,
|
||||
lokiPreviewFile,
|
||||
e2eSpecFiles,
|
||||
unitInfraTouched,
|
||||
lokiInfraTouched,
|
||||
@@ -143,16 +150,11 @@ export function createTestPlan({
|
||||
beFilesTotal,
|
||||
}: CreateTestPlanInput): TestPlan {
|
||||
const rulesGraph = buildModuleGraph(elements, rules);
|
||||
const usageGraph = fileDependencies
|
||||
? buildUsageModuleGraph(elements, fileDependencies)
|
||||
: rulesGraph;
|
||||
|
||||
const nodes = rulesGraph.nodes;
|
||||
// Distinct module types (an element type can span several patterns).
|
||||
const totalModules = new Set(elements.map((el) => el.type)).size;
|
||||
const changedModules = getChangedModules(nodes, changedFiles);
|
||||
const rulesAffected = getAffectedModules(rulesGraph, changedFiles);
|
||||
const usageAffected = getAffectedModules(usageGraph, changedFiles);
|
||||
|
||||
// The coarse "feature" tier is the only set the e2e manifest is ever
|
||||
// collapsed to (see filterAffectedE2eSpecs).
|
||||
@@ -161,7 +163,7 @@ export function createTestPlan({
|
||||
// cljc/cljs compile into the FE bundle, so they force a full run that module
|
||||
// selection can't narrow — same effect as a suite's own infra changing.
|
||||
const unitForceAll = unitInfraTouched || sharedSourcesTouched;
|
||||
const lokiForceAll = lokiInfraTouched || sharedSourcesTouched;
|
||||
const lokiInfraForceAll = lokiInfraTouched || sharedSourcesTouched;
|
||||
// e2e is integration-level, so anything the FE-coverage manifest can't see
|
||||
// forces a full run: cljc/cljs in the bundle (sharedSourcesTouched), a backend
|
||||
// change that can break the UI (beFilesChanged), an e2e harness/support change
|
||||
@@ -172,6 +174,28 @@ export function createTestPlan({
|
||||
beFilesChanged > 0 ||
|
||||
e2eSpecFiles === null;
|
||||
|
||||
// The cruise takes seconds, so it is skipped when every suite runs in full anyway.
|
||||
const edges =
|
||||
unitForceAll && lokiInfraForceAll && e2eForceAll
|
||||
? null
|
||||
: loadFileDependencies();
|
||||
const usageGraph = edges
|
||||
? buildUsageModuleGraph(elements, edges)
|
||||
: rulesGraph;
|
||||
const usageAffected = getAffectedModules(usageGraph, changedFiles);
|
||||
|
||||
// The preview wraps every story, so a change to a file it imports can change any screenshot.
|
||||
const previewDependencies = edges
|
||||
? getTransitiveDependencies(edges, lokiPreviewFile)
|
||||
: new Set<string>();
|
||||
const lokiPreviewTouched = changedFiles.some((file) =>
|
||||
previewDependencies.has(file),
|
||||
);
|
||||
// Without the cruise, the rules graph cannot account for the preview's
|
||||
// global dependencies, so it cannot safely narrow Loki.
|
||||
const lokiForceAll =
|
||||
lokiInfraForceAll || lokiPreviewTouched || edges === null;
|
||||
|
||||
const select = (forceAll: boolean, affected: Set<string>, files: string[]) =>
|
||||
forceAll ? files : filterAffectedTests(nodes, affected, files);
|
||||
|
||||
@@ -208,6 +232,7 @@ export function createTestPlan({
|
||||
be_files_total: beFilesTotal,
|
||||
unit_infra_touched: unitInfraTouched,
|
||||
loki_infra_touched: lokiInfraTouched,
|
||||
loki_preview_touched: lokiPreviewTouched,
|
||||
shared_sources_touched: sharedSourcesTouched,
|
||||
fe_modules_total: totalModules,
|
||||
fe_modules_changed: changedModules.size,
|
||||
|
||||
107
.github/scripts/affected-tests.unit.spec.ts
vendored
@@ -26,8 +26,9 @@ const E2E_FILES = ["e2e/test/scenarios/a.cy.spec.ts"];
|
||||
const baseInput = {
|
||||
elements: ELEMENTS,
|
||||
rules: RULES,
|
||||
fileDependencies: null,
|
||||
loadFileDependencies: () => null,
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e: E2E_FILES },
|
||||
lokiPreviewFile: ".storybook/preview.tsx",
|
||||
e2eSpecFiles: null,
|
||||
unitInfraTouched: false,
|
||||
lokiInfraTouched: false,
|
||||
@@ -41,7 +42,10 @@ const baseInput = {
|
||||
|
||||
describe("createTestPlan", () => {
|
||||
it("runs only specs in affected modules (rules graph)", () => {
|
||||
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
});
|
||||
|
||||
expect(plan.stats.fe_modules_changed).toBe(1);
|
||||
expect(plan.stats.fe_modules_affected_rules).toBe(1);
|
||||
@@ -50,8 +54,8 @@ describe("createTestPlan", () => {
|
||||
"src/foo/foo.unit.spec.ts",
|
||||
]);
|
||||
expect(plan.stats.unit_specs_to_run_rules).toBe(2);
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(1);
|
||||
// With no file deps, usage falls back to rules.
|
||||
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
|
||||
// With no file deps, unit usage falls back to rules, but Loki runs in full.
|
||||
expect(plan.stats.unit_specs_to_run_usage).toBe(2);
|
||||
});
|
||||
|
||||
@@ -60,7 +64,7 @@ describe("createTestPlan", () => {
|
||||
...baseInput,
|
||||
changedFiles: ["src/utils/colors.ts"],
|
||||
// Only feature/foo actually imports lib/utils.
|
||||
fileDependencies: [
|
||||
loadFileDependencies: () => [
|
||||
{ source: "src/foo/foo.tsx", dependencies: ["src/utils/colors.ts"] },
|
||||
],
|
||||
});
|
||||
@@ -91,14 +95,69 @@ describe("createTestPlan", () => {
|
||||
...baseInput,
|
||||
changedFiles: ["docs/readme.md"],
|
||||
unitInfraTouched: true,
|
||||
loadFileDependencies: () => [],
|
||||
});
|
||||
|
||||
expect(plan.stats.unit_specs_to_run_rules).toBe(UNIT_FILES.length);
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(0);
|
||||
});
|
||||
|
||||
// The preview imports the theme, which imports the colors.
|
||||
const PREVIEW_DEPS = [
|
||||
{ source: ".storybook/preview.tsx", dependencies: ["src/utils/theme.ts"] },
|
||||
{ source: "src/utils/theme.ts", dependencies: ["src/utils/colors.ts"] },
|
||||
{ source: "src/foo/foo.tsx", dependencies: ["src/foo/x.ts"] },
|
||||
];
|
||||
|
||||
it("forces a full Loki run when the Storybook preview imports a changed file", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/utils/colors.ts"],
|
||||
loadFileDependencies: () => PREVIEW_DEPS,
|
||||
e2eSpecFiles: { "e2e/test/scenarios/a.cy.spec.ts": ["src/bar/b.ts"] },
|
||||
});
|
||||
|
||||
expect(plan.stats.loki_preview_touched).toBe(true);
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(LOKI_FILES.length);
|
||||
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
|
||||
// Unit and e2e still narrow to what the usage graph reaches from the change.
|
||||
expect(plan.fe_unit_specs_to_run).toEqual(["src/utils/utils.unit.spec.ts"]);
|
||||
expect(plan.e2e_specs_to_run).toEqual([]);
|
||||
});
|
||||
|
||||
it("narrows Loki when the Storybook preview does not import the changed file", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
loadFileDependencies: () => PREVIEW_DEPS,
|
||||
});
|
||||
|
||||
expect(plan.stats.loki_preview_touched).toBe(false);
|
||||
expect(plan.loki_stories_to_run).toEqual(["src/foo/Foo.stories.tsx"]);
|
||||
});
|
||||
|
||||
it("runs all Loki stories when the cruise fails even if rules select fewer", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
loadFileDependencies: () => null,
|
||||
e2eSpecFiles: { "e2e/test/scenarios/a.cy.spec.ts": ["src/bar/b.ts"] },
|
||||
});
|
||||
|
||||
expect(plan.stats.loki_preview_touched).toBe(false);
|
||||
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(LOKI_FILES.length);
|
||||
expect(plan.stats.loki_stories_to_run_usage).toBe(LOKI_FILES.length);
|
||||
// Unit and E2E keep their rules-graph fallback.
|
||||
expect(plan.fe_unit_specs_to_run).toEqual(UNIT_FILES.slice(0, 2));
|
||||
expect(plan.e2e_specs_to_run).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the full e2e suite when no coverage manifest is available", () => {
|
||||
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
});
|
||||
|
||||
expect(plan.stats.e2e_specs_to_run_rules).toBe(E2E_FILES.length);
|
||||
expect(plan.stats.e2e_specs_to_run_usage).toBe(E2E_FILES.length);
|
||||
@@ -229,21 +288,53 @@ describe("createTestPlan", () => {
|
||||
expect(plan.stats.fe_files_changed).toBe(5);
|
||||
expect(plan.stats.be_files_changed).toBe(3);
|
||||
});
|
||||
|
||||
it("does not build the usage graph when every suite runs in full", () => {
|
||||
const loadFileDependencies = jest.fn(() => null);
|
||||
createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
sharedSourcesTouched: true,
|
||||
loadFileDependencies,
|
||||
});
|
||||
|
||||
expect(loadFileDependencies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds the usage graph when any suite can be narrowed", () => {
|
||||
const loadFileDependencies = jest.fn(() => null);
|
||||
createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
unitInfraTouched: true, // loki can still be narrowed
|
||||
loadFileDependencies,
|
||||
});
|
||||
|
||||
expect(loadFileDependencies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAffectedTests", () => {
|
||||
it("keeps only tests inside affected modules", () => {
|
||||
it("keeps tests inside affected modules, drops tests in unaffected ones", () => {
|
||||
const nodes = buildNodes(ELEMENTS);
|
||||
const affected = new Set(["feature/foo", "lib/utils"]);
|
||||
const tests = [
|
||||
"src/foo/foo.unit.spec.ts",
|
||||
"src/utils/colors.unit.spec.ts",
|
||||
"src/bar/bar.unit.spec.ts",
|
||||
"docs/unrelated.unit.spec.ts",
|
||||
];
|
||||
expect(filterAffectedTests(nodes, affected, tests)).toEqual([
|
||||
"src/foo/foo.unit.spec.ts",
|
||||
"src/utils/colors.unit.spec.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a test that maps to no module (unknown scope)", () => {
|
||||
const nodes = buildNodes(ELEMENTS);
|
||||
const affected = new Set(["feature/foo"]);
|
||||
const tests = ["docs/unrelated.unit.spec.ts", "src/bar/bar.unit.spec.ts"];
|
||||
expect(filterAffectedTests(nodes, affected, tests)).toEqual([
|
||||
"docs/unrelated.unit.spec.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
182
.github/scripts/check-unit-test-results.ts
vendored
Normal file
@@ -0,0 +1,182 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const TEST_UNIT_COMMAND = ["bun", "run", "test-unit-keep-cljs"];
|
||||
|
||||
type SuiteResult = {
|
||||
name: string;
|
||||
status: string;
|
||||
assertionResults: unknown[];
|
||||
};
|
||||
|
||||
type JestResults = {
|
||||
wasInterrupted: boolean;
|
||||
testResults: SuiteResult[];
|
||||
};
|
||||
|
||||
function isSuiteResult(value: unknown): value is SuiteResult {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"name" in value &&
|
||||
typeof value.name === "string" &&
|
||||
"status" in value &&
|
||||
typeof value.status === "string" &&
|
||||
"assertionResults" in value &&
|
||||
Array.isArray(value.assertionResults)
|
||||
);
|
||||
}
|
||||
|
||||
function readResults(file: string): JestResults | null {
|
||||
const value = readJson(file);
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"wasInterrupted" in value &&
|
||||
typeof value.wasInterrupted === "boolean" &&
|
||||
"testResults" in value &&
|
||||
Array.isArray(value.testResults) &&
|
||||
value.testResults.every(isSuiteResult)
|
||||
) {
|
||||
return {
|
||||
wasInterrupted: value.wasInterrupted,
|
||||
testResults: value.testResults,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(file: string): unknown {
|
||||
try {
|
||||
return parseJson(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function hasContent(file: string): boolean {
|
||||
try {
|
||||
return statSync(file).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findRunnerFailure(env: NodeJS.ProcessEnv): string | null {
|
||||
const {
|
||||
UNIT_TEST_RESULTS_FILE,
|
||||
JEST_JUNIT_OUTPUT_DIR,
|
||||
JEST_JUNIT_OUTPUT_NAME,
|
||||
} = env;
|
||||
if (
|
||||
!UNIT_TEST_RESULTS_FILE ||
|
||||
!JEST_JUNIT_OUTPUT_DIR ||
|
||||
!JEST_JUNIT_OUTPUT_NAME
|
||||
) {
|
||||
throw new Error(
|
||||
"UNIT_TEST_RESULTS_FILE, JEST_JUNIT_OUTPUT_DIR and JEST_JUNIT_OUTPUT_NAME are required",
|
||||
);
|
||||
}
|
||||
|
||||
const results = readResults(UNIT_TEST_RESULTS_FILE);
|
||||
if (results === null || results.wasInterrupted) {
|
||||
return "Jest did not finish the run.";
|
||||
}
|
||||
if (!hasContent(join(JEST_JUNIT_OUTPUT_DIR, JEST_JUNIT_OUTPUT_NAME))) {
|
||||
return "Jest did not write a JUnit report.";
|
||||
}
|
||||
|
||||
const failedSuites = results.testResults.filter(
|
||||
(suite) => suite.status === "failed",
|
||||
);
|
||||
// jest-junit leaves out suites with no test results, so the quarantine gate never sees them.
|
||||
const suitesWithoutResults = failedSuites.filter(
|
||||
(suite) => suite.assertionResults.length === 0,
|
||||
);
|
||||
if (suitesWithoutResults.length > 0) {
|
||||
const names = suitesWithoutResults.map((suite) => suite.name).join(", ");
|
||||
return `Test suites failed to run: ${names}`;
|
||||
}
|
||||
if (failedSuites.length === 0) {
|
||||
return "Jest failed without a failing test suite.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listTests(
|
||||
command: readonly string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string[] | null {
|
||||
const [program, ...args] = command;
|
||||
const result = spawnSync(program, [...args, "--listTests", "--json"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
const listed = parseJson(result.stdout);
|
||||
return isStringArray(listed) ? listed : null;
|
||||
}
|
||||
|
||||
// --passWithNoTests lets every shard pass when the selection matches nothing Jest discovers.
|
||||
// A single empty shard is normal, so only an unsharded listing that is also empty warns.
|
||||
export function findEmptySelection(
|
||||
env: NodeJS.ProcessEnv,
|
||||
command: readonly string[] = TEST_UNIT_COMMAND,
|
||||
): string | null {
|
||||
const { UNIT_TEST_RESULTS_FILE, JEST_TEST_PATHS_FILE } = env;
|
||||
if (!UNIT_TEST_RESULTS_FILE || !JEST_TEST_PATHS_FILE) {
|
||||
return null;
|
||||
}
|
||||
const results = readResults(UNIT_TEST_RESULTS_FILE);
|
||||
const selection = readJson(JEST_TEST_PATHS_FILE);
|
||||
if (
|
||||
results === null ||
|
||||
results.testResults.length > 0 ||
|
||||
!isStringArray(selection) ||
|
||||
selection.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const listed = listTests(command, env);
|
||||
if (listed === null) {
|
||||
return "Could not list Jest's unit tests to check the selection.";
|
||||
}
|
||||
if (listed.length > 0) {
|
||||
return null;
|
||||
}
|
||||
return `None of the ${selection.length} selected unit specs match a test Jest discovers, so no unit tests ran.`;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.env.TEST_OUTCOME === "failure") {
|
||||
const failure = findRunnerFailure(process.env);
|
||||
if (failure !== null) {
|
||||
console.error(`::error::${failure}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
const warning = findEmptySelection(process.env);
|
||||
if (warning !== null) {
|
||||
console.warn(`::warning::${warning}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
99
.github/scripts/create-test-plan.ts
vendored
@@ -1,28 +1,31 @@
|
||||
// I/O entrypoint: gather inputs (env vars, the cruise graph, the test-file
|
||||
// lists) and hand them to createTestPlan, which does the computing.
|
||||
// I/O entrypoint: gather inputs (env vars, the test-file lists, the cruise graph on request),
|
||||
// hand them to createTestPlan, which does the computing, and publish its small stats output.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
|
||||
import micromatch from "micromatch";
|
||||
|
||||
import { MAIN_APP_STORY_GLOBS } from "../../.storybook/story-files.cjs";
|
||||
import { elements, rules } from "../../frontend/lint/module-boundaries.mjs";
|
||||
|
||||
import { type FileDependency, parseCruiseModules } from "./affected-modules";
|
||||
import { createTestPlan } from "./affected-tests";
|
||||
import { listSpecFiles } from "./e2e-spec-globs.mjs";
|
||||
|
||||
const UNIT_ROOTS = ["frontend/src", "enterprise/frontend/src"];
|
||||
// The specs `bun run test-unit` runs.
|
||||
const UNIT_ROOTS = ["."];
|
||||
const UNIT_GLOBS = [
|
||||
"frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
|
||||
"enterprise/frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
|
||||
"**/*.unit.spec.{js,jsx,ts,tsx}",
|
||||
"!.github/**", // the ci-scripts project, which test-unit ignores
|
||||
"!release/**", // has its own jest config
|
||||
"!enterprise/frontend/src/custom-viz/**", // modulePathIgnorePatterns in jest.config.js
|
||||
"!**/*.leak.unit.spec.{js,jsx,ts,tsx}", // run only by jest.memory.conf.js
|
||||
];
|
||||
|
||||
const STORY_ROOTS = ["frontend", "enterprise/frontend"];
|
||||
const STORY_GLOBS = [
|
||||
"frontend/**/*.stories.{js,jsx,ts,tsx}",
|
||||
"enterprise/frontend/**/*.stories.{js,jsx,ts,tsx}",
|
||||
];
|
||||
const FRONTEND_ROOTS = ["frontend", "enterprise/frontend"];
|
||||
|
||||
const LOKI_PREVIEW_FILE = ".storybook/preview.tsx";
|
||||
|
||||
// `git ls-files -- frontend enterprise/frontend` already prints just over a
|
||||
// megabyte of paths, and node's default maxBuffer is exactly 1 MiB: past that
|
||||
@@ -32,12 +35,11 @@ const LS_FILES_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
// Returns the tracked files under `roots` that match `globs`. The `dot: true`
|
||||
// option means files inside dot-directories such as `.storybook` are included.
|
||||
function listFiles(roots: string[], globs: string[]): string[] {
|
||||
const tracked = execFileSync("git", ["ls-files", "--", ...roots], {
|
||||
const tracked = execFileSync("git", ["ls-files", "-z", "--", ...roots], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: LS_FILES_MAX_BUFFER,
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
return micromatch(tracked, globs, { dot: true });
|
||||
}
|
||||
@@ -49,26 +51,43 @@ const csvToList = (csv: string | undefined) =>
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Reads the dependency-cruiser graph (DEP_GRAPH_JSON). Null falls back to the
|
||||
// rules graph, so a missing or unparseable file never breaks the plan.
|
||||
function readFileDependencies(): FileDependency[] | null {
|
||||
const path = process.env.DEP_GRAPH_JSON;
|
||||
if (path && existsSync(path)) {
|
||||
try {
|
||||
const { modules } = JSON.parse(readFileSync(path, "utf8"));
|
||||
process.stderr.write(`Using usage graph from ${path}.\n`);
|
||||
return parseCruiseModules(modules);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`Failed to read ${path}; falling back to rules graph: ${error}\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
process.stderr.write(
|
||||
"No DEP_GRAPH_JSON found; falling back to rules graph.\n",
|
||||
// Runs dependency-cruiser over the frontend sources and the Storybook preview and parses its edges.
|
||||
// Null falls back to the rules graph, so a failed cruise never breaks the plan.
|
||||
function loadFileDependencies(): FileDependency[] | null {
|
||||
const output = "dependency-graph.json";
|
||||
process.stderr.write("Building usage graph with dependency-cruiser.\n");
|
||||
try {
|
||||
// stdout is the plan JSON, so the cruise's own output goes to stderr.
|
||||
const result = spawnSync(
|
||||
"bunx",
|
||||
[
|
||||
"depcruise",
|
||||
"frontend/src",
|
||||
"enterprise/frontend/src",
|
||||
LOKI_PREVIEW_FILE,
|
||||
"--config",
|
||||
".dependency-cruiser.cjs",
|
||||
"--output-type",
|
||||
"json",
|
||||
"--output-to",
|
||||
output,
|
||||
],
|
||||
{ stdio: ["ignore", 2, 2] },
|
||||
);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`depcruise exited with status ${result.status}`);
|
||||
}
|
||||
const { modules } = JSON.parse(readFileSync(output, "utf8"));
|
||||
return parseCruiseModules(modules);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`Failed to build usage graph; falling back to rules graph: ${error}\n`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reads the nightly coverage manifest (E2E_SPEC_MANIFEST): { builtAt, specs:
|
||||
@@ -98,12 +117,13 @@ const testPlan = createTestPlan({
|
||||
elements,
|
||||
rules,
|
||||
changedFiles: csvToList(process.env.CHANGED_FILES),
|
||||
fileDependencies: readFileDependencies(),
|
||||
loadFileDependencies,
|
||||
testFilesBySuite: {
|
||||
unit: listFiles(UNIT_ROOTS, UNIT_GLOBS),
|
||||
loki: listFiles(STORY_ROOTS, STORY_GLOBS),
|
||||
loki: listFiles(FRONTEND_ROOTS, MAIN_APP_STORY_GLOBS),
|
||||
e2e: listSpecFiles(),
|
||||
},
|
||||
lokiPreviewFile: LOKI_PREVIEW_FILE,
|
||||
e2eSpecFiles: readE2eSpecFiles(),
|
||||
unitInfraTouched: process.env.UNIT_INFRA_TOUCHED === "true",
|
||||
lokiInfraTouched: process.env.LOKI_INFRA_TOUCHED === "true",
|
||||
@@ -111,8 +131,15 @@ const testPlan = createTestPlan({
|
||||
sharedSourcesTouched: process.env.SHARED_SOURCES_TOUCHED === "true",
|
||||
feFilesChanged: csvToList(process.env.FE_CHANGED_FILES).length,
|
||||
beFilesChanged: csvToList(process.env.BE_CHANGED_FILES).length,
|
||||
feFilesTotal: listFiles(["frontend", "enterprise/frontend"], ["**"]).length,
|
||||
feFilesTotal: listFiles(FRONTEND_ROOTS, ["**"]).length,
|
||||
beFilesTotal: listFiles(["src", "enterprise/backend"], ["**"]).length,
|
||||
});
|
||||
|
||||
process.stdout.write(JSON.stringify(testPlan) + "\n");
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`stats=${JSON.stringify(testPlan.stats)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
86
.github/scripts/file-paths.unit.spec.ts
vendored
@@ -11,12 +11,17 @@ type Filter = string | Filter[] | { [changeType: string]: Filter };
|
||||
// A filter is a list of patterns, and an entry may name the change types it applies to as
|
||||
// { "added|modified": pattern }. Either way the patterns are what matters here.
|
||||
const patterns = (value: Filter): string[] => {
|
||||
if (typeof value === "string") return [value];
|
||||
if (Array.isArray(value)) return value.flatMap(patterns);
|
||||
if (typeof value === "string") {
|
||||
return [value];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(patterns);
|
||||
}
|
||||
return Object.values(value).flatMap(patterns);
|
||||
};
|
||||
|
||||
describe("file-paths.yaml", () => {
|
||||
// js-yaml returns an untyped value, and this repository file defines named path filters.
|
||||
const filters = load(readFileSync(FILTERS, "utf8")) as Record<string, Filter>;
|
||||
|
||||
// dorny/paths-filter matches with picomatch, which micromatch wraps, so this asks the same
|
||||
@@ -44,14 +49,47 @@ describe("file-paths.yaml", () => {
|
||||
"%s skips a ratchets-only change and still follows the rest of .clj-kondo",
|
||||
(name) => {
|
||||
expect(matches(name, ".clj-kondo/ratchets.edn")).toBe(false);
|
||||
expect(
|
||||
matches(name, ".clj-kondo/config/modules/ratchets.edn"),
|
||||
).toBe(false);
|
||||
expect(matches(name, ".clj-kondo/config/modules/ratchets.edn")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(matches(name, ".clj-kondo/config.edn")).toBe(true);
|
||||
expect(matches(name, ".clj-kondo/config/modules/config.edn")).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("runs CI script tests when the Node version changes", () => {
|
||||
expect(matches("ci_scripts", ".nvmrc")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"jest.config.js",
|
||||
"jest.base.conf.js",
|
||||
"jest.esm-packages.js",
|
||||
"jest.tz.unit.conf.js",
|
||||
])("runs every frontend unit test when %s changes", (path) => {
|
||||
expect(matches("frontend_specs", path)).toBe(true);
|
||||
expect(matches("frontend_unit_infra", path)).toBe(true);
|
||||
});
|
||||
|
||||
it("runs every frontend unit test when an SDK jest setup file changes", () => {
|
||||
expect(
|
||||
matches(
|
||||
"frontend_unit_infra",
|
||||
"frontend/src/embedding-sdk-shared/jest/setup-env.ts",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"jest.config.js",
|
||||
"jest.base.conf.js",
|
||||
"jest.esm-packages.js",
|
||||
".storybook/story-files.cjs",
|
||||
".github/workflows/loki.yml",
|
||||
])("runs CI script tests when %s changes", (path) => {
|
||||
expect(matches("ci_scripts", path)).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the ratchet check on the ratchets file", () => {
|
||||
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
|
||||
true,
|
||||
@@ -63,4 +101,42 @@ describe("file-paths.yaml", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"loki.config.js",
|
||||
".storybook/story-files.cjs",
|
||||
".storybook/preview.tsx",
|
||||
".loki/reference/chrome.laptop-Button.png",
|
||||
".github/workflows/loki.yml",
|
||||
".github/workflows/run-tests.yml",
|
||||
".github/actions/prepare-frontend/action.yml",
|
||||
"frontend/build/shared/rspack/css-config.js",
|
||||
"frontend/test/__support__/custom-viz-fixtures/calendar-heatmap/index.js",
|
||||
"patches/@loki+browser+0.35.0.patch",
|
||||
"frontend/src/metabase/css/index.module.css",
|
||||
"frontend/src/metabase/css/core/base.module.css",
|
||||
"resources/frontend_client/app/fonts/Lato/lato-v16-latin-regular.woff2",
|
||||
"resources/frontend_client/app/assets/img/no_results.svg",
|
||||
"frontend/src/metabase/ui/components/icons/Icon/icons/warning.svg",
|
||||
"enterprise/frontend/src/metabase-enterprise/google_drive/database-error.svg",
|
||||
])("runs all Loki stories when %s changes", (file) => {
|
||||
expect(matches("frontend_loki_all", file)).toBe(true);
|
||||
expect(matches("frontend_loki_infra", file)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"frontend/src/metabase/ui/Button.stories.tsx",
|
||||
"frontend/src/metabase/ui/components/feedback/Alert/Alert.module.css",
|
||||
"frontend/build/embedding-sdk/rspack.config.js",
|
||||
])("allows a change to %s to narrow the Loki run", (file) => {
|
||||
expect(matches("frontend_loki_all", file)).toBe(true);
|
||||
expect(matches("frontend_loki_infra", file)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
".github/workflows/frontend.yml",
|
||||
"frontend/test/metabase/scenarios/Button.unit.spec.tsx",
|
||||
])("does not run Loki stories when %s changes", (file) => {
|
||||
expect(matches("frontend_loki_all", file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
237
.github/scripts/frontend-test-plan.unit.spec.ts
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { load } from "js-yaml";
|
||||
|
||||
import {
|
||||
findEmptySelection,
|
||||
findRunnerFailure,
|
||||
} from "./check-unit-test-results";
|
||||
import { prepareTestSelection } from "./prepare-test-selection";
|
||||
|
||||
type Step = { name: string; run?: string; "continue-on-error"?: boolean };
|
||||
// js-yaml returns an untyped value, and this repository workflow defines the job's steps.
|
||||
const workflow = load(
|
||||
readFileSync(resolve(__dirname, "../workflows/frontend.yml"), "utf8"),
|
||||
) as { jobs: { "fe-tests-unit": { steps: Step[] } } };
|
||||
const steps = workflow.jobs["fe-tests-unit"].steps;
|
||||
const checkScript = resolve(__dirname, "check-unit-test-results.ts");
|
||||
const filter = resolve(
|
||||
__dirname,
|
||||
"../../frontend/test/jest-test-paths-filter.ts",
|
||||
);
|
||||
|
||||
describe("frontend test plan handoff", () => {
|
||||
let dir: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
// Jest reports real paths, and macOS's temporary directory is behind a symlink.
|
||||
dir = realpathSync(mkdtempSync(join(tmpdir(), "frontend-test-plan-")));
|
||||
mkdirSync(join(dir, "test-plan"));
|
||||
writeFileSync(join(dir, "output"), "");
|
||||
env = {
|
||||
...process.env,
|
||||
NODE_OPTIONS: "",
|
||||
JEST_TEST_PATHS_FILE: "",
|
||||
RUNNER_TEMP: dir,
|
||||
GITHUB_OUTPUT: join(dir, "output"),
|
||||
PLAN_DOWNLOADED: "success",
|
||||
JEST_JUNIT_OUTPUT_DIR: dir,
|
||||
JEST_JUNIT_OUTPUT_NAME: "junit.xml",
|
||||
UNIT_TEST_RESULTS_FILE: join(dir, "unit-test-results.json"),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function plan(files: string[], total = 2) {
|
||||
return {
|
||||
fe_unit_specs_to_run: files,
|
||||
stats: { unit_specs_all: total, unit_specs_to_run_usage: files.length },
|
||||
};
|
||||
}
|
||||
|
||||
function writePlan(value: unknown) {
|
||||
writeFileSync(join(dir, "test-plan/test-plan.json"), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function jestCommand(useFilter: boolean) {
|
||||
return [
|
||||
process.execPath,
|
||||
require.resolve("jest/bin/jest"),
|
||||
"--config",
|
||||
JSON.stringify({
|
||||
rootDir: dir,
|
||||
testMatch: ["**/*.spec.cjs"],
|
||||
reporters: ["default", require.resolve("jest-junit")],
|
||||
...(useFilter && { filter }),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function runJest(
|
||||
files: Record<string, string>,
|
||||
useFilter = false,
|
||||
args: string[] = [],
|
||||
) {
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
writeFileSync(join(dir, name), source);
|
||||
}
|
||||
const [program, ...commandArgs] = jestCommand(useFilter);
|
||||
return spawnSync(
|
||||
program,
|
||||
[
|
||||
...commandArgs,
|
||||
"--runInBand",
|
||||
"--watch=false",
|
||||
"--json",
|
||||
`--outputFile=${env.UNIT_TEST_RESULTS_FILE}`,
|
||||
...(useFilter ? ["--passWithNoTests"] : []),
|
||||
...args,
|
||||
],
|
||||
{ cwd: dir, env, encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
function readResults() {
|
||||
return JSON.parse(
|
||||
readFileSync(join(dir, "unit-test-results.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
function writeSelection(files: string[]) {
|
||||
env.JEST_TEST_PATHS_FILE = join(dir, "unit-specs.json");
|
||||
writeFileSync(env.JEST_TEST_PATHS_FILE, JSON.stringify(files));
|
||||
}
|
||||
|
||||
it("lets ordinary assertion failures reach quarantine", () => {
|
||||
expect(
|
||||
runJest({ "fail.spec.cjs": "test('fails', () => expect(1).toBe(2));" })
|
||||
.status,
|
||||
).toBe(1);
|
||||
expect(findRunnerFailure(env)).toBeNull();
|
||||
});
|
||||
|
||||
it("lets suite hook failures reach quarantine", () => {
|
||||
expect(
|
||||
runJest({
|
||||
"hook.spec.cjs":
|
||||
"test('passes', () => {}); afterAll(() => { throw new Error('hook'); });",
|
||||
}).status,
|
||||
).toBe(1);
|
||||
expect(readFileSync(join(dir, "junit.xml"), "utf8")).toContain(
|
||||
"Test execution failure",
|
||||
);
|
||||
expect(findRunnerFailure(env)).toBeNull();
|
||||
});
|
||||
|
||||
it("fails a filter crash even though the test step permits errors", () => {
|
||||
env.JEST_TEST_PATHS_FILE = join(dir, "broken.json");
|
||||
writeFileSync(env.JEST_TEST_PATHS_FILE, "[");
|
||||
expect(
|
||||
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true).status,
|
||||
).toBe(1);
|
||||
env.TEST_OUTCOME = "failure";
|
||||
const result = spawnSync("bun", [checkScript], { env, encoding: "utf8" });
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("::error::");
|
||||
const step = steps.find((step) =>
|
||||
step.run?.includes("check-unit-test-results.ts"),
|
||||
);
|
||||
expect(step?.["continue-on-error"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails suite startup errors even alongside ordinary assertion failures", () => {
|
||||
expect(
|
||||
runJest({
|
||||
"crash.spec.cjs": "throw new Error('startup crash');",
|
||||
"fail.spec.cjs": "test('fails', () => expect(1).toBe(2));",
|
||||
}).status,
|
||||
).toBe(1);
|
||||
expect(readFileSync(join(dir, "junit.xml"), "utf8")).not.toContain(
|
||||
"crash.spec.cjs",
|
||||
);
|
||||
expect(findRunnerFailure(env)).toContain("crash.spec.cjs");
|
||||
});
|
||||
|
||||
it("fails when JUnit results are missing", () => {
|
||||
runJest({ "fail.spec.cjs": "test('fails', () => expect(1).toBe(2));" });
|
||||
rmSync(join(dir, "junit.xml"));
|
||||
expect(findRunnerFailure(env)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"loads the TypeScript filter natively with an empty selection: %s",
|
||||
(empty) => {
|
||||
writePlan(plan(empty ? [] : [join(dir, "pass.spec.cjs")]));
|
||||
prepareTestSelection("unit", env);
|
||||
env.JEST_TEST_PATHS_FILE = join(dir, "unit-specs.json");
|
||||
const result = runJest(
|
||||
{
|
||||
"pass.spec.cjs": "test('passes', () => {});",
|
||||
"fail.spec.cjs":
|
||||
"test('excluded', () => { throw new Error('must not run'); });",
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(result.status === 0 ? "" : result.stderr).toBe("");
|
||||
expect(result.status).toBe(0);
|
||||
expect(readResults()).toMatchObject({
|
||||
numTotalTestSuites: empty ? 0 : 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("warns when a narrowed run matches none of Jest's test paths", () => {
|
||||
writeSelection([join(dir, "renamed.spec.cjs")]);
|
||||
expect(
|
||||
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true).status,
|
||||
).toBe(0);
|
||||
expect(findEmptySelection(env, jestCommand(true))).toContain(
|
||||
"None of the 1 selected unit specs",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns through Bun when the unit test step passed", () => {
|
||||
writeSelection([join(dir, "renamed.spec.cjs")]);
|
||||
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true);
|
||||
env.TEST_OUTCOME = "success";
|
||||
const result = spawnSync("bun", [checkScript], { env, encoding: "utf8" });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain(
|
||||
"::warning::None of the 1 selected unit specs",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn for a shard the selection leaves empty", () => {
|
||||
writeSelection([join(dir, "pass.spec.cjs")]);
|
||||
const result = runJest(
|
||||
{ "pass.spec.cjs": "test('passes', () => {});" },
|
||||
true,
|
||||
["--shard=2/2"],
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(readResults()).toMatchObject({ numTotalTestSuites: 0 });
|
||||
expect(findEmptySelection(env, jestCommand(true))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not list tests for an empty selection", () => {
|
||||
writeSelection([]);
|
||||
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true);
|
||||
expect(findEmptySelection(env, ["false"])).toBeNull();
|
||||
});
|
||||
});
|
||||
44
.github/scripts/jest-test-paths-filter.unit.spec.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Match Jest's direct CommonJS loading contract.
|
||||
const filter = require("../../frontend/test/jest-test-paths-filter");
|
||||
|
||||
describe("jest-test-paths-filter", () => {
|
||||
const originalPathsFile = process.env.JEST_TEST_PATHS_FILE;
|
||||
let tempDir: string;
|
||||
let pathsFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "jest-test-paths-"));
|
||||
pathsFile = path.join(tempDir, "paths.json");
|
||||
process.env.JEST_TEST_PATHS_FILE = pathsFile;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (originalPathsFile === undefined) {
|
||||
delete process.env.JEST_TEST_PATHS_FILE;
|
||||
} else {
|
||||
process.env.JEST_TEST_PATHS_FILE = originalPathsFile;
|
||||
}
|
||||
});
|
||||
|
||||
it("should keep the listed paths and drop the rest", () => {
|
||||
const one = path.resolve("frontend/one.unit.spec.ts");
|
||||
const two = path.resolve("frontend/two.unit.spec.ts");
|
||||
const three = path.resolve("frontend/three.unit.spec.ts");
|
||||
fs.writeFileSync(pathsFile, JSON.stringify([one, two]));
|
||||
|
||||
expect(filter([one, three, two])).toEqual({ filtered: [one, two] });
|
||||
});
|
||||
|
||||
it("should resolve relative paths against the working directory", () => {
|
||||
fs.writeFileSync(pathsFile, JSON.stringify(["frontend/one.unit.spec.ts"]));
|
||||
const one = path.resolve("frontend/one.unit.spec.ts");
|
||||
const two = path.resolve("frontend/two.unit.spec.ts");
|
||||
|
||||
expect(filter([one, two])).toEqual({ filtered: [one] });
|
||||
});
|
||||
});
|
||||
20
.github/scripts/measure-bundle-sizes.js
vendored
@@ -24,6 +24,15 @@ const SDK_ROOT = "resources/frontend_client/app/embedding-sdk";
|
||||
const SDK_RUNTIME_RE = /embedding-sdk-chunk-runtime\./;
|
||||
const notRuntime = name => !SDK_RUNTIME_RE.test(name);
|
||||
|
||||
// One locale catalogue per chunk, named by the `webpackChunkName: "locale-[request]"`
|
||||
// comment in localization.ts. A session loads exactly one and an English session
|
||||
// loads none, so counting all 36 in a reachable total overstates it by 35x: 8.5 MB
|
||||
// gzip against the SDK's 3.1 MB. Their size also tracks the translation process
|
||||
// rather than any code change, so counting them makes every catalogue update look
|
||||
// like a bundle regression.
|
||||
const LOCALE_CHUNK_RE = /(^|\/)locale-/;
|
||||
const notLocale = name => !LOCALE_CHUNK_RE.test(name);
|
||||
|
||||
const assetFiles = (root, names) => names.map(name => path.join(root, name));
|
||||
const unique = files => [...new Set(files)];
|
||||
|
||||
@@ -116,7 +125,8 @@ function selectAppAssets(stats) {
|
||||
if (!initialAssets) {
|
||||
throw new Error('app: stats-main.json / "app-main" entrypoint missing');
|
||||
}
|
||||
return { initialAssets, reachableAssets: entrypointJsAssets(stats, "app-main", "reachableAssets") };
|
||||
const reachableAssets = entrypointJsAssets(stats, "app-main", "reachableAssets");
|
||||
return { initialAssets, reachableAssets: reachableAssets?.filter(notLocale) ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +146,9 @@ function selectLegacySdkAssets(stats, { sdkAsyncChunksLoadable }) {
|
||||
const initial = unique(initialNames).filter(notRuntime);
|
||||
return {
|
||||
initialNames: initial,
|
||||
reachableNames: sdkAsyncChunksLoadable ? unique(reachableNames).filter(notRuntime) : initial,
|
||||
reachableNames: sdkAsyncChunksLoadable
|
||||
? unique(reachableNames).filter(notRuntime).filter(notLocale)
|
||||
: initial,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,7 +173,9 @@ function selectChunkedSdkAssets(stats, { sdkAsyncChunksLoadable }) {
|
||||
const bootstrapReachable = entrypointJsAssets(stats, "embedding-sdk-bootstrap", "reachableAssets") || bootstrapInitial;
|
||||
return {
|
||||
initialNames: unique([...bootstrapInitial, ...chunkedInitial]).filter(notRuntime),
|
||||
reachableNames: unique([...bootstrapReachable, ...chunkedReachable]).filter(notRuntime),
|
||||
reachableNames: unique([...bootstrapReachable, ...chunkedReachable])
|
||||
.filter(notRuntime)
|
||||
.filter(notLocale),
|
||||
includesAsyncChunks: chunkedReachableAssets != null && sdkAsyncChunksLoadable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
} = require("./measure-bundle-sizes");
|
||||
|
||||
const RUNTIME = "embedding-sdk-chunk-runtime.deadbeef.js";
|
||||
const LOCALE = "chunks/locale-fr-json.deadbeef.js";
|
||||
|
||||
const stats = entrypoints => ({ entrypoints });
|
||||
|
||||
@@ -30,6 +31,13 @@ describe("selectAppAssets", () => {
|
||||
it("throws when the app-main entrypoint is missing", () => {
|
||||
expect(() => selectAppAssets(stats({}))).toThrow("app-main");
|
||||
});
|
||||
|
||||
it("leaves the locale catalogues out of the reachable total", () => {
|
||||
const s = stats({
|
||||
"app-main": { assets: ["main.js"], reachableAssets: ["main.js", "locale-fr-json.abc.js"] },
|
||||
});
|
||||
expect(selectAppAssets(s).reachableAssets).toEqual(["main.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectChunkedSdkAssets", () => {
|
||||
@@ -50,6 +58,15 @@ describe("selectChunkedSdkAssets", () => {
|
||||
expect(result.includesAsyncChunks).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the locale catalogues out of the reachable total", () => {
|
||||
const withLocale = {
|
||||
...base,
|
||||
"embedding-sdk-chunked": { assets: ["a.js", RUNTIME], reachableAssets: ["a.js", "b.js", LOCALE, RUNTIME] },
|
||||
};
|
||||
const result = selectChunkedSdkAssets(stats(withLocale), { sdkAsyncChunksLoadable: true });
|
||||
expect(result.reachableNames).toEqual(["boot.js", "a.js", "b.js"]);
|
||||
});
|
||||
|
||||
it("collapses total to initial when the stats carry no reachable graph", () => {
|
||||
const noGraph = { "embedding-sdk-chunked": { assets: ["a.js"] } };
|
||||
const result = selectChunkedSdkAssets(stats(noGraph), { sdkAsyncChunksLoadable: true });
|
||||
|
||||
122
.github/scripts/prepare-test-selection.ts
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { TestPlan, TestPlanStats } from "./affected-tests";
|
||||
|
||||
type NumberStat = {
|
||||
[K in keyof TestPlanStats]: TestPlanStats[K] extends number ? K : never;
|
||||
}[keyof TestPlanStats];
|
||||
|
||||
type Suite = {
|
||||
name: string;
|
||||
filesKey: Exclude<keyof TestPlan, "stats">;
|
||||
totalKey: NumberStat;
|
||||
selectedKey: NumberStat;
|
||||
pathsFile: string;
|
||||
};
|
||||
|
||||
export const SUITES = {
|
||||
unit: {
|
||||
name: "unit",
|
||||
filesKey: "fe_unit_specs_to_run",
|
||||
totalKey: "unit_specs_all",
|
||||
selectedKey: "unit_specs_to_run_usage",
|
||||
pathsFile: "unit-specs.json",
|
||||
},
|
||||
loki: {
|
||||
name: "Loki",
|
||||
filesKey: "loki_stories_to_run",
|
||||
totalKey: "loki_stories_all",
|
||||
selectedKey: "loki_stories_to_run_usage",
|
||||
pathsFile: "loki-stories.json",
|
||||
},
|
||||
e2e: {
|
||||
name: "E2E",
|
||||
filesKey: "e2e_specs_to_run",
|
||||
totalKey: "e2e_specs_all",
|
||||
selectedKey: "e2e_specs_to_run_usage",
|
||||
pathsFile: "e2e-specs.json",
|
||||
},
|
||||
} satisfies Record<string, Suite>;
|
||||
|
||||
function isSuiteName(name: string): name is keyof typeof SUITES {
|
||||
return Object.hasOwn(SUITES, name);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isCount(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
// null means a full run, and [] is an explicit selection of no tests.
|
||||
function readSelection(planFile: string, suite: Suite): string[] | null {
|
||||
const plan: unknown = JSON.parse(readFileSync(planFile, "utf8"));
|
||||
const files = isRecord(plan) ? plan[suite.filesKey] : undefined;
|
||||
const stats = isRecord(plan) && isRecord(plan.stats) ? plan.stats : {};
|
||||
const total = stats[suite.totalKey];
|
||||
if (
|
||||
!isStringArray(files) ||
|
||||
files.some((file) => file.length === 0) ||
|
||||
!isCount(total) ||
|
||||
files.length !== stats[suite.selectedKey] ||
|
||||
files.length > total
|
||||
) {
|
||||
throw new Error(`Invalid ${suite.name} test selection`);
|
||||
}
|
||||
return files.length === total ? null : files;
|
||||
}
|
||||
|
||||
export function prepareTestSelection(
|
||||
suiteName: keyof typeof SUITES,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const suite: Suite = SUITES[suiteName];
|
||||
const { RUNNER_TEMP, GITHUB_OUTPUT, PLAN_DOWNLOADED } = env;
|
||||
if (!RUNNER_TEMP || !GITHUB_OUTPUT) {
|
||||
throw new Error("RUNNER_TEMP and GITHUB_OUTPUT are required");
|
||||
}
|
||||
|
||||
let files: string[] | null;
|
||||
try {
|
||||
if (PLAN_DOWNLOADED !== "success") {
|
||||
throw new Error("Test plan download failed");
|
||||
}
|
||||
files = readSelection(join(RUNNER_TEMP, "test-plan/test-plan.json"), suite);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`::warning::Test plan unavailable or invalid, running the full ${suite.name} suite: ${error}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files === null) {
|
||||
appendFileSync(GITHUB_OUTPUT, "selection=full\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const pathsFile = join(RUNNER_TEMP, suite.pathsFile);
|
||||
writeFileSync(pathsFile, JSON.stringify(files));
|
||||
appendFileSync(
|
||||
GITHUB_OUTPUT,
|
||||
`paths-file=${pathsFile}\nselection=${files.length === 0 ? "empty" : "narrowed"}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const suiteName = process.argv[2] ?? "";
|
||||
if (!isSuiteName(suiteName)) {
|
||||
throw new Error(
|
||||
`Unknown test suite "${suiteName}", expected one of: ${Object.keys(SUITES).join(", ")}`,
|
||||
);
|
||||
}
|
||||
prepareTestSelection(suiteName);
|
||||
}
|
||||
268
.github/scripts/prepare-test-selection.unit.spec.ts
vendored
Normal file
@@ -0,0 +1,268 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { load } from "js-yaml";
|
||||
|
||||
import { SUITES, prepareTestSelection } from "./prepare-test-selection";
|
||||
|
||||
type Suite = (typeof SUITES)[keyof typeof SUITES];
|
||||
|
||||
type Step = {
|
||||
id?: string;
|
||||
if?: string;
|
||||
uses?: string;
|
||||
run?: string;
|
||||
env?: Record<string, string>;
|
||||
with?: Record<string, string | boolean>;
|
||||
};
|
||||
|
||||
const SCRIPT = resolve(__dirname, "prepare-test-selection.ts");
|
||||
const SUITE_NAMES = ["unit", "loki", "e2e"] satisfies (keyof typeof SUITES)[];
|
||||
|
||||
function loadWorkflow(file: string) {
|
||||
// js-yaml returns an untyped value, and these repository workflows define jobs with steps.
|
||||
return load(
|
||||
readFileSync(resolve(__dirname, "../workflows", file), "utf8"),
|
||||
) as {
|
||||
jobs: Record<
|
||||
string,
|
||||
{ if?: string; outputs?: Record<string, string>; steps?: Step[] }
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
function loadAction() {
|
||||
// js-yaml returns an untyped value, and this repository action defines outputs and steps.
|
||||
return load(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../actions/prepare-test-selection/action.yml"),
|
||||
"utf8",
|
||||
),
|
||||
) as {
|
||||
outputs: Record<string, { value: string }>;
|
||||
runs: { steps: Step[] };
|
||||
};
|
||||
}
|
||||
|
||||
describe("prepareTestSelection", () => {
|
||||
const { loki } = SUITES;
|
||||
let dir: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
dir = mkdtempSync(join(tmpdir(), "test-selection-"));
|
||||
mkdirSync(join(dir, "test-plan"));
|
||||
writeFileSync(join(dir, "output"), "");
|
||||
env = {
|
||||
...process.env,
|
||||
RUNNER_TEMP: dir,
|
||||
GITHUB_OUTPUT: join(dir, "output"),
|
||||
PLAN_DOWNLOADED: "success",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function plan(files: unknown, total = 2, suite: Suite = loki) {
|
||||
return {
|
||||
[suite.filesKey]: files,
|
||||
stats: {
|
||||
[suite.totalKey]: total,
|
||||
[suite.selectedKey]: Array.isArray(files) ? files.length : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writePlan(value: unknown) {
|
||||
writeFileSync(join(dir, "test-plan/test-plan.json"), JSON.stringify(value));
|
||||
}
|
||||
|
||||
const output = () => readFileSync(join(dir, "output"), "utf8");
|
||||
|
||||
it.each([{ files: ["one.spec.cjs"] }, { files: [] }])(
|
||||
"preserves an explicit selection: $files",
|
||||
({ files }) => {
|
||||
writePlan(plan(files));
|
||||
prepareTestSelection("loki", env);
|
||||
expect(output()).toBe(
|
||||
`paths-file=${join(dir, loki.pathsFile)}\nselection=${files.length > 0 ? "narrowed" : "empty"}\n`,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(dir, loki.pathsFile), "utf8")),
|
||||
).toEqual(files);
|
||||
},
|
||||
);
|
||||
|
||||
it("writes the selection when invoked through Bun in CI", () => {
|
||||
writePlan(plan(["one.spec.cjs"]));
|
||||
const result = spawnSync("bun", [SCRIPT, "loki"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status === 0 ? "" : result.stderr).toBe("");
|
||||
expect(result.status).toBe(0);
|
||||
expect(output()).toBe(
|
||||
`paths-file=${join(dir, loki.pathsFile)}\nselection=narrowed\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes no paths file for a full selection", () => {
|
||||
writePlan(plan(["one.spec.cjs", "two.spec.cjs"]));
|
||||
prepareTestSelection("loki", env);
|
||||
expect(output()).toBe("selection=full\n");
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
plan("[]"),
|
||||
plan([null]),
|
||||
{ ...plan([]), [loki.filesKey]: ["one.spec.cjs"] },
|
||||
plan([""]),
|
||||
plan(["one.spec.cjs"], 0),
|
||||
])("runs in full for an invalid plan: %j", (value) => {
|
||||
writePlan(value);
|
||||
prepareTestSelection("loki", env);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("::warning::"),
|
||||
);
|
||||
expect(output()).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: "the test plan is missing", setup: () => {} },
|
||||
{
|
||||
scenario: "the test plan is truncated",
|
||||
setup: () => {
|
||||
writeFileSync(
|
||||
join(dir, "test-plan/test-plan.json"),
|
||||
`{"${loki.filesKey}":[`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "the download failed",
|
||||
setup: () => {
|
||||
writePlan(plan([])); // Even a partial download must not be trusted.
|
||||
env.PLAN_DOWNLOADED = "failure";
|
||||
},
|
||||
},
|
||||
])("runs in full when $scenario", ({ setup }) => {
|
||||
setup();
|
||||
prepareTestSelection("loki", env);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("::warning::"),
|
||||
);
|
||||
expect(output()).toBe("");
|
||||
});
|
||||
|
||||
it.each(SUITE_NAMES)(
|
||||
"reads the %s selection from its own plan keys",
|
||||
(suiteName) => {
|
||||
const suite = SUITES[suiteName];
|
||||
writePlan(plan(["one.spec.cjs"], 2, suite));
|
||||
prepareTestSelection(suiteName, env);
|
||||
expect(output()).toBe(
|
||||
`paths-file=${join(dir, suite.pathsFile)}\nselection=narrowed\n`,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(dir, suite.pathsFile), "utf8")),
|
||||
).toEqual(["one.spec.cjs"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores the other suites' selections", () => {
|
||||
writePlan({
|
||||
...plan(["one.spec.cjs", "two.spec.cjs"]),
|
||||
[SUITES.unit.filesKey]: [],
|
||||
[SUITES.e2e.filesKey]: [],
|
||||
});
|
||||
prepareTestSelection("loki", env);
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
expect(output()).toBe("selection=full\n");
|
||||
});
|
||||
|
||||
it("rejects an unknown suite", () => {
|
||||
const result = spawnSync("bun", [SCRIPT, "toString"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('Unknown test suite "toString"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("test selection workflow steps", () => {
|
||||
const ACTION = "./.github/actions/prepare-test-selection";
|
||||
const CONSUMERS: Record<string, { suite: keyof typeof SUITES }> = {
|
||||
"frontend.yml": { suite: "unit" },
|
||||
"loki.yml": { suite: "loki" },
|
||||
};
|
||||
|
||||
it("skips Loki visual tests only for an empty story selection", () => {
|
||||
const { jobs } = loadWorkflow("loki.yml");
|
||||
expect(jobs["story-selection"].outputs?.selection).toBe(
|
||||
"${{ steps.loki-selection.outputs.selection }}",
|
||||
);
|
||||
expect(jobs["visual-test"].if).toContain(
|
||||
"needs.story-selection.outputs.selection != 'empty'",
|
||||
);
|
||||
expect(jobs["visual-test"].if).not.toMatch(/outputs\.[\w-]+ *[!=]= *'\d+'/);
|
||||
});
|
||||
|
||||
it.each(Object.entries(CONSUMERS))(
|
||||
"selects tests through the composite action in %s",
|
||||
(file, { suite, ...inputs }) => {
|
||||
const steps = Object.values(loadWorkflow(file).jobs).flatMap(
|
||||
({ steps = [] }) => steps.filter((step) => step.uses === ACTION),
|
||||
);
|
||||
|
||||
expect(steps.length).toBeGreaterThan(0);
|
||||
for (const step of steps) {
|
||||
expect(step.with).toEqual({
|
||||
suite,
|
||||
"test-plan-artifact-id": expect.stringMatching(/^\$\{\{ .+ \}\}$/),
|
||||
...inputs,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("downloads the test plan before preparing the selection", () => {
|
||||
const { outputs, runs } = loadAction();
|
||||
const [download, prepare] = runs.steps;
|
||||
|
||||
expect(download).toMatchObject({
|
||||
uses: expect.stringMatching(/^actions\/download-artifact@/),
|
||||
"continue-on-error": "${{ inputs.require-download != 'true' }}",
|
||||
with: {
|
||||
"artifact-ids": "${{ inputs.test-plan-artifact-id }}",
|
||||
path: "${{ runner.temp }}/test-plan",
|
||||
},
|
||||
});
|
||||
expect(prepare).toMatchObject({
|
||||
env: { PLAN_DOWNLOADED: `\${{ steps.${download.id}.outcome }}` },
|
||||
run: expect.stringContaining(
|
||||
'prepare-test-selection.ts "${{ inputs.suite }}"',
|
||||
),
|
||||
});
|
||||
expect(outputs).toMatchObject({
|
||||
"paths-file": {
|
||||
value: `\${{ steps.${prepare.id}.outputs.paths-file }}`,
|
||||
},
|
||||
selection: { value: `\${{ steps.${prepare.id}.outputs.selection }}` },
|
||||
});
|
||||
});
|
||||
});
|
||||
9
.github/scripts/run-backend-test-jvms.sh
vendored
@@ -98,6 +98,15 @@ merge_outputs() {
|
||||
if [ -f "$jvm_dir/logs/test-log.json" ]; then
|
||||
cp -a "$jvm_dir/logs/test-log.json" "$WORKSPACE/logs/test-log-p$idx.json" || true
|
||||
fi
|
||||
# Flamegraphs, when the run was profiled. Flat with a `p$idx-` prefix, not nested: the
|
||||
# collect-profile action globs `results/*flamegraph.html` without descending.
|
||||
if [ -d "$jvm_dir/target/clj-async-profiler/results" ]; then
|
||||
mkdir -p "$WORKSPACE/target/clj-async-profiler/results"
|
||||
for f in "$jvm_dir"/target/clj-async-profiler/results/*flamegraph.html; do
|
||||
[ -e "$f" ] || continue
|
||||
cp -a "$f" "$WORKSPACE/target/clj-async-profiler/results/p$idx-$(basename "$f")" || true
|
||||
done
|
||||
fi
|
||||
done
|
||||
}
|
||||
trap merge_outputs EXIT
|
||||
|
||||
226
.github/scripts/story-files.unit.spec.ts
vendored
Normal file
@@ -0,0 +1,226 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import micromatch, { isMatch } from "micromatch";
|
||||
import { normalizeStories } from "storybook/internal/common";
|
||||
|
||||
import {
|
||||
MAIN_APP_STORY_GLOBS,
|
||||
getStories,
|
||||
hasExplicitTitle,
|
||||
} from "../../.storybook/story-files.cjs";
|
||||
|
||||
const ROOT = resolve(__dirname, "../..");
|
||||
|
||||
const tracked = execFileSync(
|
||||
"git",
|
||||
["ls-files", "-z", "--", "frontend", "enterprise/frontend"],
|
||||
{ cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
||||
)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const planned = micromatch(tracked, MAIN_APP_STORY_GLOBS, { dot: true }).sort();
|
||||
|
||||
describe("getStories", () => {
|
||||
let tempDir: string;
|
||||
let pathsFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "story-files-"));
|
||||
pathsFile = join(tempDir, "stories.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("loads the main app stories and docs when there is no plan", () => {
|
||||
const patterns = getStories({ pathsFile: "", filter: "" });
|
||||
|
||||
expect(
|
||||
isMatch("../frontend/src/metabase/ui/Button.stories.tsx", patterns),
|
||||
).toBe(true);
|
||||
expect(isMatch("../frontend/src/metabase/ui/Button.mdx", patterns)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isMatch(
|
||||
"../frontend/src/embedding-sdk-shared/Button.stories.tsx",
|
||||
patterns,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("builds only the files in the JSON plan", () => {
|
||||
const files = planned.slice(0, 2);
|
||||
writeFileSync(pathsFile, JSON.stringify(files));
|
||||
|
||||
expect(getStories({ pathsFile })).toEqual(
|
||||
files.map((file) => `../${file}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a planned story with an explicit title", () => {
|
||||
const story = join(tempDir, "Titled.stories.tsx");
|
||||
writeFileSync(
|
||||
story,
|
||||
'const meta = { title: "Components/Titled" };\nexport default meta;\n',
|
||||
);
|
||||
writeFileSync(pathsFile, JSON.stringify([story]));
|
||||
|
||||
expect(getStories({ pathsFile })).toEqual([`../${story}`]);
|
||||
});
|
||||
|
||||
it("rejects a planned story without an explicit title", () => {
|
||||
const story = join(tempDir, "Untitled.stories.tsx");
|
||||
writeFileSync(story, "export default { component: Untitled };\n");
|
||||
writeFileSync(pathsFile, JSON.stringify([story]));
|
||||
|
||||
expect(() => getStories({ pathsFile })).toThrow(story);
|
||||
});
|
||||
|
||||
it("rejects an untitled Alert story even though its args have a title", () => {
|
||||
const source = readFileSync(
|
||||
join(
|
||||
ROOT,
|
||||
"frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.tsx",
|
||||
),
|
||||
"utf8",
|
||||
).replace(' title: "Components/Feedback/Alert",\n', "");
|
||||
const story = join(tempDir, "Alert.stories.tsx");
|
||||
writeFileSync(story, source);
|
||||
writeFileSync(pathsFile, JSON.stringify([story]));
|
||||
|
||||
expect(() => getStories({ pathsFile })).toThrow(story);
|
||||
});
|
||||
|
||||
it("rejects an empty plan even when the CSV filter is set", () => {
|
||||
writeFileSync(pathsFile, "[]");
|
||||
|
||||
expect(() =>
|
||||
getStories({ pathsFile, filter: "frontend/src/Unselected.stories.tsx" }),
|
||||
).toThrow("No stories selected");
|
||||
});
|
||||
|
||||
it("preserves the stress test's CSV filter", () => {
|
||||
expect(
|
||||
getStories({
|
||||
filter: "frontend/One.stories.tsx,frontend/Two.stories.tsx",
|
||||
}),
|
||||
).toEqual(["../frontend/One.stories.tsx", "../frontend/Two.stories.tsx"]);
|
||||
});
|
||||
|
||||
it.each(["{", "null", '""', "{}", "[null]"])(
|
||||
"rejects an invalid plan: %s",
|
||||
(contents) => {
|
||||
writeFileSync(pathsFile, contents);
|
||||
|
||||
expect(() => getStories({ pathsFile })).toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("hasExplicitTitle", () => {
|
||||
it.each([
|
||||
{
|
||||
form: "double quotes",
|
||||
source: 'export default {\n title: "Components/Button",\n};\n',
|
||||
},
|
||||
{
|
||||
form: "single quotes",
|
||||
source: "export default {\n title: 'Components/Button',\n};\n",
|
||||
},
|
||||
{
|
||||
form: "a title constant",
|
||||
source: 'const title = "Components/Button"; export default { title };',
|
||||
},
|
||||
{
|
||||
form: "a meta constant",
|
||||
source:
|
||||
'const meta: Meta<typeof Button> = {\n title: "Components/Button",\n};\n\nexport default meta;\n',
|
||||
},
|
||||
{
|
||||
form: "a satisfies expression",
|
||||
source:
|
||||
'const meta = { title: "Components/Button" } satisfies Meta<typeof Button>; export default meta;',
|
||||
},
|
||||
])("accepts a title in $form", ({ source }) => {
|
||||
expect(hasExplicitTitle(source)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
form: "no title",
|
||||
source: "export default {\n component: Button,\n};\n",
|
||||
},
|
||||
{
|
||||
form: "only a title control",
|
||||
source:
|
||||
'const argTypes = {\n title: {\n control: { type: "text" },\n },\n};\n\nexport default { component: Button, argTypes };\n',
|
||||
},
|
||||
{
|
||||
form: "only a title variable",
|
||||
source:
|
||||
"export default {\n component: Button,\n args: {\n title: defaultTitle,\n },\n};\n",
|
||||
},
|
||||
{
|
||||
form: "only a string-valued args title",
|
||||
source: 'export default { args: { title: "Button label" } };',
|
||||
},
|
||||
{
|
||||
form: "only another object's title",
|
||||
source:
|
||||
'const other = { title: "Other" }; export default { component: Button };',
|
||||
},
|
||||
{
|
||||
form: "only a commented title",
|
||||
source:
|
||||
'export default {\n // title: "Components/Button",\n component: Button };',
|
||||
},
|
||||
{
|
||||
form: "an empty title",
|
||||
source: 'export default { title: "" };',
|
||||
},
|
||||
{
|
||||
form: "a template literal unsupported by Storybook",
|
||||
source: "export default { title: `Components/Button` };",
|
||||
},
|
||||
])("rejects $form", ({ source }) => {
|
||||
expect(hasExplicitTitle(source)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAIN_APP_STORY_GLOBS", () => {
|
||||
it("matches the same tracked files as Storybook's own story matcher", () => {
|
||||
const specifiers = normalizeStories(getStories(), {
|
||||
configDir: join(ROOT, ".storybook"),
|
||||
workingDir: ROOT,
|
||||
});
|
||||
const built = tracked
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.endsWith(".mdx") &&
|
||||
specifiers.some((specifier) =>
|
||||
specifier.importPathMatcher.test(`./${file}`),
|
||||
),
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(planned.length).toBeGreaterThan(0);
|
||||
expect(planned).toEqual(built);
|
||||
});
|
||||
|
||||
it("matches no docs entries", () => {
|
||||
expect(planned.filter((file) => file.endsWith(".mdx"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches only stories with an explicit title", () => {
|
||||
const untitled = planned.filter(
|
||||
(file) => !hasExplicitTitle(readFileSync(join(ROOT, file), "utf8")),
|
||||
);
|
||||
|
||||
expect(untitled).toEqual([]);
|
||||
});
|
||||
});
|
||||
8
.github/scripts/upload-bundle-load-stats.ts
vendored
@@ -23,9 +23,13 @@ interface Condition {
|
||||
coldLargestPaintMs: number;
|
||||
coldPageReadyMs: number;
|
||||
warmPageReadyMs: number;
|
||||
warmLargestPaintMs: number;
|
||||
steadyLargestPaintMs: number;
|
||||
steadyPageReadyMs: number;
|
||||
locale: string;
|
||||
scripts: number;
|
||||
scriptKb: number;
|
||||
cssKb: number;
|
||||
totalKb: number;
|
||||
runs: number;
|
||||
}
|
||||
@@ -69,9 +73,13 @@ function buildRows(
|
||||
"Cold largest paint ms": condition.coldLargestPaintMs,
|
||||
"Cold page ready ms": condition.coldPageReadyMs,
|
||||
"Warm page ready ms": condition.warmPageReadyMs,
|
||||
"Warm largest paint ms": condition.warmLargestPaintMs,
|
||||
"Steady largest paint ms": condition.steadyLargestPaintMs,
|
||||
"Steady page ready ms": condition.steadyPageReadyMs,
|
||||
Locale: condition.locale,
|
||||
Scripts: condition.scripts,
|
||||
"Script kb": condition.scriptKb,
|
||||
"Css kb": condition.cssKb,
|
||||
"Total kb": condition.totalKb,
|
||||
Runs: condition.runs,
|
||||
}));
|
||||
|
||||
73
.github/scripts/upload-e2e-test-timings.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Collect some stats about our e2e tests:
|
||||
* - total number of spec files
|
||||
* - total time to run all specs
|
||||
* - average spec runtime
|
||||
* - max spec runtime
|
||||
*
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { importStats, StatsRow } from "./stats-import";
|
||||
|
||||
const TABLE = "e2e_test_timings";
|
||||
|
||||
export interface Timings {
|
||||
durations?: { spec?: string; duration?: unknown }[];
|
||||
}
|
||||
|
||||
export const buildTestTimingsRow = (
|
||||
timestamp: string,
|
||||
timings: Timings,
|
||||
): StatsRow => {
|
||||
const durations = timings.durations ?? [];
|
||||
const specCount = durations.length;
|
||||
|
||||
if (specCount === 0) {
|
||||
throw new Error("No durations found.");
|
||||
}
|
||||
|
||||
let totalRunTime = 0;
|
||||
let maxSpecRuntime = 0;
|
||||
for (const { duration } of durations) {
|
||||
if (typeof duration !== "number") {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalRunTime += duration;
|
||||
maxSpecRuntime = duration > maxSpecRuntime ? duration : maxSpecRuntime;
|
||||
}
|
||||
|
||||
const averageSpecRuntime = Math.round(totalRunTime / specCount);
|
||||
|
||||
return {
|
||||
date: timestamp,
|
||||
spec_count: specCount,
|
||||
total_runtime: totalRunTime,
|
||||
avg_spec_runtime: averageSpecRuntime,
|
||||
max_spec_runtime: maxSpecRuntime,
|
||||
};
|
||||
};
|
||||
|
||||
export async function main() {
|
||||
const path = process.env.TIMINGS_PATH || "e2e/support/timings.json";
|
||||
|
||||
try {
|
||||
const timings = JSON.parse(readFileSync(path, "utf-8")) as Timings;
|
||||
const row = buildTestTimingsRow(new Date().toISOString(), timings);
|
||||
console.log(row);
|
||||
|
||||
await importStats({ table: TABLE, rows: [row] });
|
||||
console.log(`Successfully uploaded e2e test timings to ${TABLE}.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(
|
||||
`::warning::Uploading e2e test timings from ${path} failed; leaving run green: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((import.meta as ImportMeta & { main?: boolean }).main) {
|
||||
main();
|
||||
}
|
||||
61
.github/scripts/upload-e2e-test-timings.unit.spec.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
import { buildTestTimingsRow } from "./upload-e2e-test-timings";
|
||||
|
||||
const timestamp = "2026-06-30T00:00:00.000Z";
|
||||
|
||||
describe("buildTestTimingsRow", () => {
|
||||
it("summarizes spec durations", () => {
|
||||
const row = buildTestTimingsRow(timestamp, {
|
||||
durations: [
|
||||
{ spec: "a.cy.spec.js", duration: 1000 },
|
||||
{ spec: "b.cy.spec.js", duration: 3000 },
|
||||
{ spec: "c.cy.spec.js", duration: 2000 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(row).toEqual({
|
||||
date: timestamp,
|
||||
spec_count: 3,
|
||||
total_runtime: 6000,
|
||||
avg_spec_runtime: 2000,
|
||||
max_spec_runtime: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("rounds the average runtime", () => {
|
||||
const row = buildTestTimingsRow(timestamp, {
|
||||
durations: [
|
||||
{ spec: "a.cy.spec.js", duration: 1000 },
|
||||
{ spec: "b.cy.spec.js", duration: 1001 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(row.avg_spec_runtime).toBe(1001);
|
||||
});
|
||||
|
||||
it("counts specs with a non-numeric duration but ignores their value", () => {
|
||||
const row = buildTestTimingsRow(timestamp, {
|
||||
durations: [
|
||||
{ spec: "a.cy.spec.js", duration: 1000 },
|
||||
{ spec: "b.cy.spec.js", duration: null },
|
||||
{ spec: "c.cy.spec.js" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(row).toEqual({
|
||||
date: timestamp,
|
||||
spec_count: 3,
|
||||
total_runtime: 1000,
|
||||
avg_spec_runtime: 333,
|
||||
max_spec_runtime: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when there are no durations", () => {
|
||||
expect(() => buildTestTimingsRow(timestamp, { durations: [] })).toThrow(
|
||||
"No durations found.",
|
||||
);
|
||||
expect(() => buildTestTimingsRow(timestamp, {})).toThrow(
|
||||
"No durations found.",
|
||||
);
|
||||
});
|
||||
});
|
||||
181
.github/workflows/app-db.yml
vendored
@@ -11,30 +11,6 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Run one app-DB job on demand, against the ref you dispatch on, optionally under a profiler.
|
||||
# Everything else about the job -- services, matrix legs, test args, quarantine gating -- is
|
||||
# whatever the normal CI run uses, because it is the same job.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app-db:
|
||||
description: 'Which app-DB job to run. Only this one runs; the others are skipped.'
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- mariadb
|
||||
- mysql
|
||||
- postgres
|
||||
tests:
|
||||
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.db.custom-migrations-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
profile:
|
||||
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-app-db
|
||||
cancel-in-progress: true
|
||||
@@ -45,14 +21,6 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Both read by the test-driver action, which is what actually runs the tests. Set here rather than
|
||||
# passed as inputs to each job so that turning either on is one line, not one line per job.
|
||||
# Empty (and so off) on workflow_call, where neither input exists.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job runs the
|
||||
# whole list -- each app DB has three.
|
||||
PROFILE_TESTS: ${{ inputs.profile }}
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
app-db-matrix:
|
||||
@@ -64,8 +32,9 @@ jobs:
|
||||
mariadb: ${{ steps.versions.outputs.mariadb }}
|
||||
mysql: ${{ steps.versions.outputs.mysql }}
|
||||
postgres: ${{ steps.versions.outputs.postgres }}
|
||||
shards: ${{ steps.versions.outputs.shards }}
|
||||
steps:
|
||||
- name: Select versions to test
|
||||
- name: Select versions and shards to test
|
||||
id: versions
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
@@ -93,9 +62,9 @@ jobs:
|
||||
],
|
||||
mysql: [
|
||||
{
|
||||
name: 'MySQL 8.0',
|
||||
image: 'mysql:8.0',
|
||||
'junit-name': 'be-tests-mysql-8-0-ee',
|
||||
name: 'MySQL 8',
|
||||
image: 'mysql:8.4',
|
||||
'junit-name': 'be-tests-mysql-8-ee',
|
||||
env: { 'enable-ssl-tests': 'false', 'enable-aws-iam-tests': 'false' },
|
||||
latest: false
|
||||
},
|
||||
@@ -133,35 +102,42 @@ jobs:
|
||||
core.setOutput(db, JSON.stringify(selected));
|
||||
}
|
||||
|
||||
const commonExcludes = ":mb/driver-tests :mb/transforms-python-test :mb/old-migrations-test";
|
||||
|
||||
const shards = [
|
||||
{
|
||||
name: 'Enterprise Tests',
|
||||
'build-static-viz': false,
|
||||
'test-args': `:only '"enterprise/backend/test"' :exclude-tags '[${commonExcludes}]'`,
|
||||
},
|
||||
{
|
||||
name: 'EE App DB Tests (Part 1)', // migrations shard
|
||||
'build-static-viz': true,
|
||||
'test-args': `:only '"test"' :only-tags '[:mb/app-db-migrations-test]' :exclude-tags '[${commonExcludes}]'`,
|
||||
},
|
||||
{
|
||||
name: 'EE App DB Tests (Part 2)',
|
||||
'build-static-viz': true,
|
||||
'test-args': `:only '"test"' :partition/total 2 :partition/index 0 :exclude-tags '[${commonExcludes} :mb/app-db-migrations-test]'`,
|
||||
},
|
||||
{
|
||||
name: 'EE App DB Tests (Part 3)',
|
||||
'build-static-viz': true,
|
||||
'test-args': `:only '"test"' :partition/total 2 :partition/index 1 :exclude-tags '[${commonExcludes} :mb/app-db-migrations-test]'`,
|
||||
},
|
||||
];
|
||||
core.setOutput('shards', JSON.stringify(shards));
|
||||
|
||||
be-tests-mariadb:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'mariadb') }}
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'mariadb') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ${{ fromJSON(needs.app-db-matrix.outputs.mariadb) }}
|
||||
job:
|
||||
- name: Enterprise Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '"enterprise/backend/test"'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 1)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 2)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 1
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
|
||||
|
||||
services:
|
||||
mysql:
|
||||
@@ -175,10 +151,6 @@ jobs:
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true
|
||||
MARIADB_DATABASE: mb_test
|
||||
env:
|
||||
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
|
||||
# GDGT-3126: re-enable and properly fix old migrations tests
|
||||
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
|
||||
# actual serious env vars below
|
||||
CI: 'true'
|
||||
DRIVERS: mysql
|
||||
MB_DB_TYPE: mysql
|
||||
@@ -205,7 +177,8 @@ jobs:
|
||||
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
|
||||
# "be-tests-mariadb-10-6-ee (0)"
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
# Shared by every app DB job below via `*app-db-test-steps`.
|
||||
steps: &app-db-test-steps
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
id: test-driver
|
||||
@@ -214,39 +187,18 @@ jobs:
|
||||
build-static-viz: ${{ matrix.job.build-static-viz }}
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
|
||||
be-tests-mysql:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'mysql') }}
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'mysql') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ${{ fromJSON(needs.app-db-matrix.outputs.mysql) }}
|
||||
job:
|
||||
- name: Enterprise Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '"enterprise/backend/test"'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 1)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 2)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 1
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
|
||||
|
||||
services:
|
||||
mysql:
|
||||
@@ -260,10 +212,6 @@ jobs:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: true
|
||||
MYSQL_DATABASE: mb_test
|
||||
env:
|
||||
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
|
||||
# GDGT-3126: re-enable and properly fix old migrations tests
|
||||
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
|
||||
# actual serious env vars below
|
||||
CI: 'true'
|
||||
DRIVERS: mysql
|
||||
MB_DB_TYPE: mysql
|
||||
@@ -298,54 +246,20 @@ jobs:
|
||||
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
|
||||
# "be-tests-mariadb-10-6-ee (0)"
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
build-static-viz: ${{ matrix.job.build-static-viz }}
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
steps: *app-db-test-steps
|
||||
|
||||
be-tests-postgres:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'postgres') }}
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'postgres') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ${{ fromJSON(needs.app-db-matrix.outputs.postgres) }}
|
||||
job:
|
||||
- name: Enterprise Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '"enterprise/backend/test"'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 1)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 2)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 1
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
env:
|
||||
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
|
||||
# GDGT-3126: re-enable and properly fix old migrations tests
|
||||
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
|
||||
# actual serious env vars below
|
||||
CI: 'true'
|
||||
DRIVERS: postgres
|
||||
MB_DB_TYPE: postgres
|
||||
@@ -374,18 +288,7 @@ jobs:
|
||||
POSTGRES_USER: mb_test
|
||||
POSTGRES_DB: mb_test
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test ${{ matrix.version.name }} (${{ matrix.job.name }})
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
build-static-viz: ${{ matrix.job.build-static-viz }}
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
steps: *app-db-test-steps
|
||||
|
||||
app-db-tests-result:
|
||||
needs:
|
||||
|
||||
33
.github/workflows/backend.yml
vendored
@@ -23,22 +23,6 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Run the backend test suite on demand, against the ref you dispatch on, optionally under a
|
||||
# profiler. Everything else about the jobs -- matrix legs, tag filters, quarantine gating -- is
|
||||
# whatever the normal CI run uses, because they are the same jobs.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tests:
|
||||
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank runs each leg''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
profile:
|
||||
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-backend
|
||||
cancel-in-progress: true
|
||||
@@ -49,13 +33,6 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Both read by the be-tests run step below. Set here rather than passed per job so that turning
|
||||
# either on is one line. Empty (and so off) on workflow_call, where neither input exists.
|
||||
#
|
||||
# ONLY_TESTS replaces the leg's own test selection, so every matrix leg runs the whole list: the
|
||||
# partition args no longer apply, and legs that hold none of the named tests run nothing.
|
||||
PROFILE_TESTS: ${{ inputs.profile }}
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
project-checks:
|
||||
@@ -374,6 +351,16 @@ jobs:
|
||||
else
|
||||
$BASE :exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]' ${{ matrix.job.test-args }}
|
||||
fi
|
||||
env:
|
||||
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. On a
|
||||
# normal CI run there are no dispatch inputs, so these render `false` and empty and the
|
||||
# leg's own selection stands.
|
||||
#
|
||||
# ONLY_TESTS replaces the leg's own test selection, so every matrix leg runs the whole
|
||||
# list: the partition args no longer apply, and legs that hold none of the named tests run
|
||||
# nothing.
|
||||
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
|
||||
ONLY_TESTS: ${{ github.event.inputs.tests }}
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
161
.github/workflows/containerize-jar.yml
vendored
@@ -8,11 +8,15 @@ on:
|
||||
description: Metabase edition (ee or oss)
|
||||
type: string
|
||||
required: true
|
||||
registry:
|
||||
description: Registry the image is pushed to, either "docker" or "ecr".
|
||||
type: string
|
||||
default: docker
|
||||
repo:
|
||||
description: Docker owner and repo, e.g. "metabase/metabase".
|
||||
description: Repository name the image is pushed to, e.g. "metabase-enterprise".
|
||||
type: string
|
||||
tag:
|
||||
description: Docker tag, e.g. v1.87.2.
|
||||
description: Image tag, e.g. v1.87.2.
|
||||
type: string
|
||||
commit:
|
||||
type: string
|
||||
@@ -21,6 +25,10 @@ on:
|
||||
default: false
|
||||
build-args:
|
||||
type: string
|
||||
platforms:
|
||||
description: Platforms the image is built for.
|
||||
type: string
|
||||
default: linux/amd64,linux/arm64
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
edition:
|
||||
@@ -30,11 +38,18 @@ on:
|
||||
- ee
|
||||
- oss
|
||||
required: true
|
||||
registry:
|
||||
description: Registry the image is pushed to
|
||||
type: choice
|
||||
options:
|
||||
- docker
|
||||
- ecr
|
||||
default: docker
|
||||
repo:
|
||||
description: Docker owner and repo, e.g. "metabase/metabase".
|
||||
description: Repository name the image is pushed to, e.g. "metabase-enterprise".
|
||||
type: string
|
||||
tag:
|
||||
description: Docker tag, e.g. v1.87.2.
|
||||
description: Image tag, e.g. v1.87.2.
|
||||
type: string
|
||||
commit:
|
||||
description: the full commit hash
|
||||
@@ -43,6 +58,10 @@ on:
|
||||
description: Whether this is a release artifact (uses metabase-release- prefix)
|
||||
type: boolean
|
||||
default: false
|
||||
platforms:
|
||||
description: Platforms the image is built for.
|
||||
type: string
|
||||
default: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
containerize:
|
||||
@@ -50,95 +69,103 @@ jobs:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
platforms: ${{ steps.platforms.outputs.result }}
|
||||
repo: ${{ steps.image.outputs.repo }}
|
||||
tag: ${{ steps.image.outputs.tag }}
|
||||
full_image: ${{ steps.image.outputs.full_image }}
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# we want the dockerfile from the commit we're building
|
||||
- run: |
|
||||
git fetch --depth=1 origin ${{ inputs.commit }}
|
||||
git checkout ${{ inputs.commit }} -- bin/docker
|
||||
|
||||
- name: Retrieve uberjar artifact
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ inputs.commit }}
|
||||
edition: ${{ inputs.edition }}
|
||||
release: ${{ inputs.release }}
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
extract-path: bin/docker
|
||||
|
||||
- name: Resolve image repo and tag
|
||||
id: image
|
||||
uses: ./.github/actions/get-docker-info
|
||||
with:
|
||||
registry: ${{ inputs.registry }}
|
||||
repo: ${{ inputs.repo }}
|
||||
tag: ${{ inputs.tag }}
|
||||
edition: ${{ inputs.edition }}
|
||||
- name: Get platforms
|
||||
id: platforms
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
result-encoding: string
|
||||
script: | # js
|
||||
const { getBuildRequirements, isValidVersionString} = require('${{ github.workspace }}/release/dist/index.cjs');
|
||||
const version = "${{ steps.image.outputs.tag }}".split('-')[0];
|
||||
aws-account-id: ${{ secrets.PR_ENV_AWS_ACCOUNT_ID }}
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
dockerhub-owner: ${{ vars.DOCKERHUB_OWNER }}
|
||||
|
||||
- name: Configure AWS credentials
|
||||
if: ${{ inputs.registry == 'ecr' }}
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
with:
|
||||
role-to-assume: ${{ secrets.PR_ENV_IAM_ROLE }}
|
||||
role-session-name: GitHub_to_AWS_via_FederatedOIDC
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
if: ${{ inputs.registry == 'ecr' }}
|
||||
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
|
||||
with:
|
||||
registries: "${{ secrets.PR_ENV_AWS_ACCOUNT_ID }}"
|
||||
|
||||
if (!isValidVersionString(version)) {
|
||||
return "linux/amd64,linux/arm64";
|
||||
}
|
||||
// versions before 53 need to be handled differently
|
||||
const buildInfo = getBuildRequirements(version);
|
||||
return buildInfo.platforms;
|
||||
- name: Move the Uberjar to the context dir
|
||||
run: mv ./metabase.jar bin/docker/.
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.registry != 'ecr' }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ inputs.release && secrets.DOCKERHUB_RELEASE_USERNAME || secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ inputs.release && secrets.DOCKERHUB_RELEASE_TOKEN || secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
if: contains(steps.platforms.outputs.result, 'arm')
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: contains(steps.platforms.outputs.result, 'arm')
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Build multi-arch container
|
||||
if: contains(steps.platforms.outputs.result, 'arm')
|
||||
|
||||
- name: Build and Push Images
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: bin/docker/.
|
||||
platforms: ${{ steps.platforms.outputs.result }}
|
||||
platforms: ${{ inputs.platforms }}
|
||||
network: host
|
||||
tags: ${{ steps.image.outputs.full_image }}
|
||||
no-cache: true
|
||||
push: true
|
||||
build-args: ${{ inputs.build-args }}
|
||||
|
||||
# we need to do this to get the nice fallback behavior of the legacy manifest file
|
||||
- name: Build container (x86 only)
|
||||
if: contains(steps.platforms.outputs.result, 'arm') == false
|
||||
env:
|
||||
BUILD_ARGS: ${{ inputs.build-args }}
|
||||
FULL_IMAGE: ${{ steps.image.outputs.full_image }}
|
||||
run: | # bash
|
||||
args=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && args+=(--build-arg "$line")
|
||||
done <<< "$BUILD_ARGS"
|
||||
- name: Post link to image on DockerHub
|
||||
if: ${{ inputs.registry != 'ecr' && github.event_name == 'pull_request' }}
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: docker-build-status-${{ inputs.edition }}
|
||||
message: |
|
||||
✅ Docker build completed for commit ${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
docker build \
|
||||
-t "$FULL_IMAGE" \
|
||||
"${args[@]}" \
|
||||
--network=host \
|
||||
--push \
|
||||
bin/docker/.
|
||||
The uberjar has been built and containerized successfully.
|
||||
|
||||
verify-docker-pull:
|
||||
**Branch:** `${{ github.head_ref || github.ref_name }}`
|
||||
**Docker Image:** `${{ steps.image.outputs.full_image }}`
|
||||
**Docker Hub:** [View on Docker Hub](https://hub.docker.com/r/${{ steps.image.outputs.repo }}/tags?name=${{ steps.image.outputs.tag }})
|
||||
**Triggered by:** @${{ github.actor }}
|
||||
|
||||
Pull the image:
|
||||
```bash
|
||||
docker pull ${{ steps.image.outputs.full_image }}
|
||||
```
|
||||
|
||||
verify-docker-pull-amd64:
|
||||
needs: containerize
|
||||
name: verify-docker-pull (${{ inputs.edition }})
|
||||
if: >-
|
||||
inputs.release &&
|
||||
inputs.registry != 'ecr'
|
||||
name: verify-docker-pull-amd64 (${{ inputs.edition }})
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -158,7 +185,10 @@ jobs:
|
||||
|
||||
verify-docker-pull-arm:
|
||||
needs: containerize
|
||||
if: contains(needs.containerize.outputs.platforms, 'linux/arm64')
|
||||
if: >-
|
||||
inputs.release &&
|
||||
inputs.registry != 'ecr' &&
|
||||
contains(inputs.platforms, 'linux/arm64')
|
||||
name: verify-docker-pull-arm (${{ inputs.edition }})
|
||||
runs-on: arm-ubuntu-24-2core
|
||||
timeout-minutes: 15
|
||||
@@ -176,34 +206,3 @@ jobs:
|
||||
- name: Wait for Metabase to start
|
||||
run: while ! curl -s 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done
|
||||
timeout-minutes: 5
|
||||
|
||||
post-docker-info-comment:
|
||||
needs: [containerize, verify-docker-pull, verify-docker-pull-arm]
|
||||
# Only post a comment if all the steps pass and this is a PR.
|
||||
if: |
|
||||
!cancelled() &&
|
||||
!failure() &&
|
||||
!inputs.release &&
|
||||
github.event_name == 'pull_request'
|
||||
name: post-docker-info-comment (${{ inputs.edition }})
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Post PR comment
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: docker-build-status-${{ inputs.edition }}
|
||||
message: |
|
||||
✅ Docker build completed for commit ${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
The uberjar has been built and containerized successfully.
|
||||
|
||||
**Branch:** `${{ github.head_ref || github.ref_name }}`
|
||||
**Docker Image:** `${{ needs.containerize.outputs.full_image }}`
|
||||
**Docker Hub:** [View on Docker Hub](https://hub.docker.com/r/${{ needs.containerize.outputs.repo }}/tags?name=${{ needs.containerize.outputs.tag }})
|
||||
**Triggered by:** @${{ github.actor }}
|
||||
|
||||
Pull the image:
|
||||
```bash
|
||||
docker pull ${{ needs.containerize.outputs.full_image }}
|
||||
```
|
||||
|
||||
59
.github/workflows/drivers.yml
vendored
@@ -10,45 +10,6 @@ on:
|
||||
description: Run the full suite regardless of the diff (master / release branches, ci:run-all label).
|
||||
type: boolean
|
||||
default: false
|
||||
# Run one driver job on demand, against the ref you dispatch on, optionally under a profiler.
|
||||
# Everything else about the job -- credentials, services, test args, quarantine gating -- is
|
||||
# whatever the normal CI run uses, because it is the same job.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
driver:
|
||||
description: 'Which driver job to run. Only this one runs; the usual decision rules are bypassed.'
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- athena
|
||||
- bigquery
|
||||
- clickhouse
|
||||
- databricks
|
||||
- druid-jdbc
|
||||
- h2
|
||||
- mongo
|
||||
- mongo-sharded-cluster
|
||||
- mongo-ssl
|
||||
- mysql-mariadb
|
||||
- oracle
|
||||
- postgres
|
||||
- presto-jdbc
|
||||
- redshift
|
||||
- snowflake
|
||||
- sparksql
|
||||
- sqlite
|
||||
- sqlserver
|
||||
- vertica
|
||||
tests:
|
||||
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.driver.bigquery-cloud-sdk-test/sync-test. Bare namespaces work too. Blank runs the job''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
profile:
|
||||
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-drivers
|
||||
@@ -60,14 +21,6 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Both read by the test-driver action, which is what actually runs the tests. Set here rather than
|
||||
# passed as inputs to each job so that turning either on is one line, not one line per driver.
|
||||
# Empty (and so off) on workflow_call, where neither input exists.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen driver runs
|
||||
# the whole list -- BigQuery, for instance, has three legs.
|
||||
PROFILE_TESTS: ${{ inputs.profile }}
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
# This job determines which driver tests should run based on PR context.
|
||||
@@ -117,7 +70,7 @@ jobs:
|
||||
--force-run="${FORCE_RUN}"
|
||||
--pr-labels="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
--skip="${{ inputs.skip }}"
|
||||
--only-driver="${ONLY_DRIVER}"
|
||||
--only-driver="${PROFILE_DRIVER}"
|
||||
)
|
||||
echo "Running:"
|
||||
echo "./bin/mage -driver-decisions ${MAGE_ARGS[*]}"
|
||||
@@ -132,7 +85,9 @@ jobs:
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref || github.ref_name }}
|
||||
FORCE_RUN: ${{ inputs.force-run }}
|
||||
ONLY_DRIVER: ${{ inputs.driver }}
|
||||
# Set only by test.profile-backend.yml. Absent on a normal CI run, which leaves the decision rules
|
||||
# alone; mage rejects a name that is not a driver.
|
||||
PROFILE_DRIVER: ${{ github.event.inputs.db }}
|
||||
shell: bash
|
||||
|
||||
be-tests-athena-ee:
|
||||
@@ -450,9 +405,9 @@ jobs:
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
- name: MySQL 8.0
|
||||
junit-name: be-tests-mysql-8-0-ee
|
||||
image: mysql:8.0
|
||||
- name: MySQL 8
|
||||
junit-name: be-tests-mysql-8-ee
|
||||
image: mysql:8.4
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
|
||||
@@ -1,42 +1,86 @@
|
||||
name: CI for PR Review ENV
|
||||
name: Deploy PR Environment
|
||||
run-name: Building Dynamic PR Environment for ${{ github.ref_name }} by @${{ github.actor }}
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
self_hosting:
|
||||
description: "Deploy self-hosting stack"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
analytics_dev_mode:
|
||||
description: "Enable analytics development mode"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
stats_dump:
|
||||
description: "Load a recent app DB stats dump on first env creation"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [ opened, labeled, synchronize, reopened ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
name: Build Metabase Docker image
|
||||
uses: ./.github/workflows/build-private-ee-docker-image.yml
|
||||
secrets:
|
||||
PR_ENV_IAM_ROLE: ${{ secrets.PR_ENV_IAM_ROLE }}
|
||||
PR_ENV_AWS_ACCOUNT_ID: ${{ secrets.PR_ENV_AWS_ACCOUNT_ID }}
|
||||
deploy_pr:
|
||||
if: >-
|
||||
${{
|
||||
github.event.pull_request.head.repo.full_name == 'metabase/metabase' &&
|
||||
(
|
||||
github.event.action != 'labeled' ||
|
||||
contains(fromJSON('["PR-Env", "PR-Env-self-hosting", "PR-Env-analytics-dev", "PR-Env-Stats-Dump"]'), github.event.label.name)
|
||||
) &&
|
||||
(
|
||||
contains(github.event.pull_request.labels.*.name, 'PR-Env') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'PR-Env-self-hosting') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'PR-Env-analytics-dev') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'PR-Env-Stats-Dump')
|
||||
)
|
||||
}}
|
||||
uses: ./.github/workflows/uberjar.yml
|
||||
secrets: inherit
|
||||
|
||||
containerize:
|
||||
needs: [build]
|
||||
uses: ./.github/workflows/containerize-jar.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
edition: ee
|
||||
registry: ecr
|
||||
repo: metabase-enterprise
|
||||
tag: ${{ github.event.pull_request.head.sha }}
|
||||
commit: ${{ github.event.pull_request.head.sha }}
|
||||
platforms: linux/amd64
|
||||
|
||||
deploy:
|
||||
needs: [containerize]
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
name: PR Review ENV
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
self_hosting: ${{ steps.label.outputs.self_hosting }}
|
||||
analytics_dev_mode: ${{ steps.label.outputs.analytics_dev_mode }}
|
||||
stats_dump: ${{ steps.label.outputs.stats_dump }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
- name: Resolve Labels
|
||||
id: label
|
||||
env:
|
||||
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
|
||||
run: | # bash
|
||||
self_hosting=false
|
||||
analytics_dev_mode=false
|
||||
stats_dump=false
|
||||
|
||||
while IFS= read -r label; do
|
||||
case "$label" in
|
||||
PR-Env-self-hosting) self_hosting=true ;;
|
||||
PR-Env-analytics-dev) analytics_dev_mode=true ;;
|
||||
PR-Env-Stats-Dump) stats_dump=true ;;
|
||||
esac
|
||||
done < <(jq -r '.[]' <<< "$LABELS")
|
||||
|
||||
echo "self_hosting=${self_hosting} analytics_dev_mode=${analytics_dev_mode} stats_dump=${stats_dump}"
|
||||
{
|
||||
echo "self_hosting=${self_hosting}"
|
||||
echo "analytics_dev_mode=${analytics_dev_mode}"
|
||||
echo "stats_dump=${stats_dump}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2
|
||||
with:
|
||||
@@ -75,7 +119,7 @@ jobs:
|
||||
PR_ENV_DB_NAME: ${{ secrets.PR_ENV_DB_NAME }}
|
||||
- name: Verify stats dump load status
|
||||
id: verify_stats_dump
|
||||
if: ${{ inputs.stats_dump }}
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' }}
|
||||
run: |
|
||||
export PGPASSWORD="${PR_ENV_DB_PASSWORD}"
|
||||
ADMIN="psql -h ${PR_ENV_DB_HOST} -U ${PR_ENV_DB_USER} -d ${PR_ENV_DB_NAME}"
|
||||
@@ -98,7 +142,7 @@ jobs:
|
||||
PR_ENV_DB_NAME: ${{ secrets.PR_ENV_DB_NAME }}
|
||||
- name: Locate latest stats dump in S3
|
||||
id: locate_stats_dump
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
env:
|
||||
STATS_DUMP_BUCKET: metabase-cloud-stats-backups-coredev
|
||||
run: |
|
||||
@@ -113,7 +157,7 @@ jobs:
|
||||
echo "Latest dump: $LATEST_KEY"
|
||||
echo "key=$LATEST_KEY" >> "$GITHUB_OUTPUT"
|
||||
- name: Download stats dump
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
env:
|
||||
STATS_DUMP_BUCKET: metabase-cloud-stats-backups-coredev
|
||||
run: |
|
||||
@@ -123,7 +167,7 @@ jobs:
|
||||
echo "Downloaded $(stat -c%s /tmp/stats-dump.dump) bytes"
|
||||
file /tmp/stats-dump.dump
|
||||
- name: Prepare app database schema
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
run: |
|
||||
PGPASSWORD="${PR_ENV_DB_PASSWORD}" psql \
|
||||
-h "${PR_ENV_DB_HOST}" \
|
||||
@@ -136,7 +180,7 @@ jobs:
|
||||
PR_ENV_DB_USER: ${{ secrets.PR_ENV_DB_USER }}
|
||||
PR_ENV_DB_HOST: ${{ secrets.PR_ENV_DB_HOST }}
|
||||
- name: Restore stats dump into app database
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
run: |
|
||||
set +e
|
||||
echo "::group::pg_restore / pg client versions"
|
||||
@@ -173,7 +217,7 @@ jobs:
|
||||
PR_ENV_DB_HOST: ${{ secrets.PR_ENV_DB_HOST }}
|
||||
PR_ENV_DB_NAME: ${{ secrets.PR_ENV_DB_NAME }}
|
||||
- name: Mark database as stats-dump loaded
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
run: |
|
||||
PGPASSWORD="${PR_ENV_DB_PASSWORD}" psql \
|
||||
-h "${PR_ENV_DB_HOST}" \
|
||||
@@ -185,22 +229,22 @@ jobs:
|
||||
PR_ENV_DB_USER: ${{ secrets.PR_ENV_DB_USER }}
|
||||
PR_ENV_DB_HOST: ${{ secrets.PR_ENV_DB_HOST }}
|
||||
- name: Download Deployment YAML template
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
if: ${{ steps.label.outputs.self_hosting != 'true' }}
|
||||
run: aws s3 cp s3://metabase-pr-env/metabase.yml.tmpl ./metabase.yml.tmpl
|
||||
- name: Render Deployment YAML
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
if: ${{ steps.label.outputs.self_hosting != 'true' }}
|
||||
uses: nowactions/envsubst@c4b8e3977354d294659a7ec164279ce8fba5c2e1 # v1.0.1
|
||||
with:
|
||||
input: ./metabase.yml.tmpl
|
||||
output: ./metabase.yml
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.build.outputs.tag }}
|
||||
IMAGE_TAG: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
MB_PREMIUM_EMBEDDING_TOKEN: ${{ secrets.PR_ENV_MB_PREMIUM_EMBEDDING_TOKEN }}
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
MB_ANALYTICS_DEV_MODE: ${{ inputs.analytics_dev_mode && 'true' || 'false' }}
|
||||
MB_ANALYTICS_DEV_MODE: ${{ steps.label.outputs.analytics_dev_mode }}
|
||||
- name: Inject stats-dump env vars into Deployment YAML
|
||||
if: ${{ !inputs.self_hosting && inputs.stats_dump }}
|
||||
if: ${{ steps.label.outputs.self_hosting != 'true' && steps.label.outputs.stats_dump == 'true' }}
|
||||
env:
|
||||
MB_ENCRYPTION_SECRET_KEY: ${{ secrets.PR_ENV_STATS_DUMP_ENCRYPTION_KEY }}
|
||||
run: |
|
||||
@@ -242,20 +286,20 @@ jobs:
|
||||
namespace: default
|
||||
user: oidc-token
|
||||
- name: Deploy PR Review ENV
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
if: ${{ steps.label.outputs.self_hosting != 'true' }}
|
||||
run: |
|
||||
kubectl apply -f ./metabase.yml
|
||||
- name: Download Helm values template (self-hosting)
|
||||
if: ${{ inputs.self_hosting }}
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
run: aws s3 cp s3://metabase-pr-env/values.yaml.tmpl ./values.yaml.tmpl
|
||||
- name: Render Helm values (self-hosting)
|
||||
if: ${{ inputs.self_hosting }}
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
uses: nowactions/envsubst@c4b8e3977354d294659a7ec164279ce8fba5c2e1 # v1.0.1
|
||||
with:
|
||||
input: ./values.yaml.tmpl
|
||||
output: ./values.yaml
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.build.outputs.tag }}
|
||||
IMAGE_TAG: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
MB_PREMIUM_EMBEDDING_TOKEN: ${{ secrets.PR_ENV_MB_PREMIUM_EMBEDDING_TOKEN }}
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
@@ -266,15 +310,15 @@ jobs:
|
||||
DB_USERNAME: ${{ secrets.PR_ENV_DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.PR_ENV_DB_PASSWORD }}
|
||||
CONFIG: ${{ secrets.PR_ENV_CONFIG }}
|
||||
MB_ANALYTICS_DEV_MODE: ${{ inputs.analytics_dev_mode && 'true' || 'false' }}
|
||||
MB_ANALYTICS_DEV_MODE: ${{ steps.label.outputs.analytics_dev_mode }}
|
||||
|
||||
- name: Setup Helm
|
||||
if: ${{ inputs.self_hosting }}
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
with:
|
||||
version: "latest"
|
||||
- name: Login to Quay.io
|
||||
if: ${{ inputs.self_hosting }}
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
run: |
|
||||
helm registry login quay.io \
|
||||
--username "${QUAY_USERNAME}" \
|
||||
@@ -283,7 +327,7 @@ jobs:
|
||||
QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }}
|
||||
QUAY_PASSWORD: ${{ secrets.QUAY_PASSWORD }}
|
||||
- name: Deploy PR Review ENV (self-hosting)
|
||||
if: ${{ inputs.self_hosting }}
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
run: |
|
||||
helm upgrade --install hosting-pr${{ github.event.number }} \
|
||||
oci://quay.io/enterprisemetabase/chart-metabase-stack \
|
||||
@@ -295,7 +339,7 @@ jobs:
|
||||
preview_links:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
needs: [deploy_pr]
|
||||
needs: [deploy]
|
||||
steps:
|
||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
@@ -305,4 +349,4 @@ jobs:
|
||||
✅ Preview:
|
||||
https://pr${{ github.event.number }}.coredev.metabase.com
|
||||
🗒️ [login instructions](https://www.notion.so/metabase/Ephemeral-PR-Environments-c74a3710c87a460dbf4fc0007a001458)
|
||||
📦 Deployment type: ${{ inputs.self_hosting && 'PR-Env-self-hosting' || (inputs.analytics_dev_mode && 'PR-Env-analytics-dev' || (inputs.stats_dump && 'PR-Env-Stats-Dump' || 'PR-Env')) }}
|
||||
📦 Deployment type: ${{ needs.deploy.outputs.self_hosting == 'true' && 'PR-Env-self-hosting' || (needs.deploy.outputs.analytics_dev_mode == 'true' && 'PR-Env-analytics-dev' || (needs.deploy.outputs.stats_dump == 'true' && 'PR-Env-Stats-Dump' || 'PR-Env')) }}
|
||||
43
.github/workflows/frontend.yml
vendored
@@ -12,6 +12,13 @@ on:
|
||||
skip-custom-viz:
|
||||
type: boolean
|
||||
default: false
|
||||
# ID of this run's test-plan.json artifact, or '' to run the full suite.
|
||||
test-plan-artifact-id:
|
||||
type: string
|
||||
default: ""
|
||||
skip-ci-scripts:
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip:
|
||||
@@ -100,6 +107,7 @@ jobs:
|
||||
SHARDS: 2 # should match matrix above
|
||||
JEST_JUNIT_OUTPUT_DIR: ./target/junit
|
||||
JEST_JUNIT_OUTPUT_NAME: test-report-frontend-unit.xml
|
||||
UNIT_TEST_RESULTS_FILE: unit-test-results.json
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
@@ -110,11 +118,29 @@ jobs:
|
||||
# Resolve JOB_ID before the long test step so the ci-conductor reporter
|
||||
# below can link failures to this exact job. Best-effort.
|
||||
uses: ./.github/actions/resolve-job-id
|
||||
- name: Prepare unit test selection
|
||||
id: unit-selection
|
||||
if: ${{ inputs.test-plan-artifact-id != '' }}
|
||||
uses: ./.github/actions/prepare-test-selection
|
||||
with:
|
||||
suite: unit
|
||||
test-plan-artifact-id: ${{ inputs.test-plan-artifact-id }}
|
||||
- name: Run frontend unit tests
|
||||
id: fe-tests
|
||||
run: bun run test-unit --silent --shard=${{ matrix.shard }}/${{ env.SHARDS }}
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
env:
|
||||
JEST_TEST_PATHS_FILE: ${{ steps.unit-selection.outputs.paths-file }}
|
||||
# Jest exits non-zero on a shard with no files unless --passWithNoTests is set.
|
||||
run: |
|
||||
rm -f "$UNIT_TEST_RESULTS_FILE" "$JEST_JUNIT_OUTPUT_DIR/$JEST_JUNIT_OUTPUT_NAME"
|
||||
bun run test-unit --silent --shard=${{ matrix.shard }}/${{ env.SHARDS }} --json --outputFile="$UNIT_TEST_RESULTS_FILE" ${{ steps.unit-selection.outputs.paths-file && '--passWithNoTests' || '' }}
|
||||
# Ordinary assertion failures can be quarantined, but runner failures cannot.
|
||||
continue-on-error: true
|
||||
- name: Check unit test results
|
||||
if: ${{ !cancelled() && (steps.fe-tests.outcome == 'failure' || steps.unit-selection.outputs.paths-file) }}
|
||||
env:
|
||||
TEST_OUTCOME: ${{ steps.fe-tests.outcome }}
|
||||
JEST_TEST_PATHS_FILE: ${{ steps.unit-selection.outputs.paths-file }}
|
||||
run: bun .github/scripts/check-unit-test-results.ts
|
||||
- name: Report failed tests to ci-conductor
|
||||
if: ${{ !cancelled() && ( steps.fe-tests.outcome == 'failure' || github.run_attempt != '1' ) }}
|
||||
# This step should NEVER fail the job!
|
||||
@@ -144,6 +170,18 @@ jobs:
|
||||
- name: Run frontend timezones tests
|
||||
run: bun run test-timezones
|
||||
|
||||
# No CLJS compile: the CI-script specs don't import app code.
|
||||
fe-tests-ci-scripts:
|
||||
if: ${{ !inputs.skip-ci-scripts }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Run CI script tests
|
||||
run: bun run test-ci-scripts
|
||||
|
||||
# The custom-viz package is an isolated Bun workspace under
|
||||
# enterprise/frontend/src/custom-viz
|
||||
fe-custom-viz:
|
||||
@@ -174,6 +212,7 @@ jobs:
|
||||
- fe-type-check
|
||||
- fe-tests-unit
|
||||
- fe-tests-timezones
|
||||
- fe-tests-ci-scripts
|
||||
- fe-custom-viz
|
||||
if: always() && !cancelled()
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
run: NODE_ENV=development bun run build-pure:cljs
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun run build-storybook
|
||||
run: bun run build-storybook:test
|
||||
env:
|
||||
STORYBOOK_STORIES_FILTER: ${{ needs.detect-changed-stories.outputs.story_files }}
|
||||
|
||||
|
||||
89
.github/workflows/loki.yml
vendored
@@ -4,16 +4,52 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
description: "Skip the Loki visual tests."
|
||||
type: boolean
|
||||
default: false
|
||||
test-plan-artifact-id:
|
||||
description: "ID of this run's test-plan.json artifact, or '' to run the full suite."
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-loki
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
story-selection:
|
||||
if: ${{ !inputs.skip && inputs.test-plan-artifact-id != '' }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
selection: ${{ steps.loki-selection.outputs.selection }}
|
||||
test-plan-artifact-id: ${{ steps.loki-selection.outputs.paths-file && inputs.test-plan-artifact-id || '' }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github
|
||||
/.nvmrc
|
||||
/package.json
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Prepare Node + Bun environment
|
||||
uses: ./.github/actions/prepare-node-bun
|
||||
|
||||
- name: Prepare Loki story selection
|
||||
id: loki-selection
|
||||
uses: ./.github/actions/prepare-test-selection
|
||||
with:
|
||||
suite: loki
|
||||
test-plan-artifact-id: ${{ inputs.test-plan-artifact-id }}
|
||||
|
||||
visual-test:
|
||||
if: ${{ !inputs.skip }}
|
||||
name: loki-run
|
||||
needs: story-selection
|
||||
# A missing job output compares equal to '0' in GitHub expressions,
|
||||
# so an empty selection is marked with a non-numeric value.
|
||||
if: ${{ !cancelled() && !inputs.skip && needs.story-selection.outputs.selection != 'empty' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
@@ -41,8 +77,22 @@ jobs:
|
||||
- name: Compile CLJS
|
||||
run: NODE_ENV=development bun run build-pure:cljs
|
||||
|
||||
- name: Prepare Loki story selection
|
||||
id: loki-selection
|
||||
if: ${{ needs.story-selection.outputs.test-plan-artifact-id != '' }}
|
||||
uses: ./.github/actions/prepare-test-selection
|
||||
with:
|
||||
suite: loki
|
||||
test-plan-artifact-id: ${{ needs.story-selection.outputs.test-plan-artifact-id }}
|
||||
|
||||
- name: Build Storybook
|
||||
env:
|
||||
STORYBOOK_STORY_PATHS_FILE: ${{ steps.loki-selection.outputs.paths-file }}
|
||||
run: bun run build-storybook:test
|
||||
|
||||
- name: Run Loki Visual Tests
|
||||
run: bun run test-visual:loki:ci
|
||||
# Loki drops stories with parameters.loki.skip, and exits non-zero when that leaves the selection empty.
|
||||
run: bun run test-visual:loki --reactUri file:./storybook-static --verboseRenderer ${{ steps.loki-selection.outputs.paths-file && '--passWithNoStories --requireReference' || '' }}
|
||||
|
||||
- name: Generate Visual Report on Failure
|
||||
if: failure()
|
||||
@@ -57,3 +107,38 @@ jobs:
|
||||
include-hidden-files: true
|
||||
path: .loki/
|
||||
if-no-files-found: ignore
|
||||
|
||||
loki-tests-result:
|
||||
name: visual-test
|
||||
needs: [story-selection, visual-test]
|
||||
if: always() && !cancelled()
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check Loki job status
|
||||
uses: actions/github-script@v9
|
||||
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
|
||||
}));
|
||||
|
||||
// are all jobs skipped or successful?
|
||||
if (jobs.every(job => (job.result === 'skipped' || job.result === 'success'))) {
|
||||
console.log('All jobs are skipped or successful');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// otherwise, something failed
|
||||
console.log('Some Loki jobs failed');
|
||||
jobs.forEach((job) => {
|
||||
if (job.result !== 'success') {
|
||||
console.log(`${job.name} - ${job.result}`);
|
||||
}
|
||||
});
|
||||
process.exit(1);
|
||||
|
||||
52
.github/workflows/pr-env-labeled.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: PR-Env labeled
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ labeled ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
uberjar:
|
||||
if: ${{ github.event.label.name == 'PR-Env' || github.event.label.name == 'PR-Env-self-hosting' || github.event.label.name == 'PR-Env-analytics-dev' || github.event.label.name == 'PR-Env-Stats-Dump' }}
|
||||
uses: ./.github/workflows/uberjar.yml
|
||||
secrets: inherit
|
||||
|
||||
pr-env:
|
||||
needs: [uberjar]
|
||||
if: ${{ github.event.label.name == 'PR-Env' }}
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: false
|
||||
|
||||
pr-env-self-hosting:
|
||||
needs: [uberjar]
|
||||
if: ${{ github.event.label.name == 'PR-Env-self-hosting' }}
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: true
|
||||
analytics_dev_mode: false
|
||||
|
||||
pr-env-analytics-dev:
|
||||
needs: [uberjar]
|
||||
if: ${{ github.event.label.name == 'PR-Env-analytics-dev' }}
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: true
|
||||
|
||||
pr-env-stats-dump:
|
||||
needs: [uberjar]
|
||||
if: ${{ github.event.label.name == 'PR-Env-Stats-Dump' }}
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: false
|
||||
stats_dump: true
|
||||
4
.github/workflows/pre-release.yml
vendored
@@ -252,7 +252,7 @@ jobs:
|
||||
with:
|
||||
edition: oss
|
||||
commit: ${{ inputs.commit }}
|
||||
repo: ${{ vars.DOCKERHUB_OWNER }}/${{ vars.DOCKERHUB_STAGING_REPO }}
|
||||
repo: ${{ vars.DOCKERHUB_STAGING_REPO }}
|
||||
tag: ${{ needs.release-artifact.outputs.oss_version }}-${{ inputs.commit }}
|
||||
release: true
|
||||
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
with:
|
||||
edition: ee
|
||||
commit: ${{ inputs.commit }}
|
||||
repo: ${{ vars.DOCKERHUB_OWNER }}/${{ vars.DOCKERHUB_STAGING_REPO }}
|
||||
repo: ${{ vars.DOCKERHUB_STAGING_REPO }}
|
||||
tag: ${{ needs.release-artifact.outputs.ee_version }}-${{ inputs.commit }}
|
||||
release: true
|
||||
|
||||
|
||||
5
.github/workflows/release.yml
vendored
@@ -279,7 +279,7 @@ jobs:
|
||||
with:
|
||||
edition: oss
|
||||
commit: ${{ inputs.commit }}
|
||||
repo: ${{ vars.DOCKERHUB_OWNER }}/${{ vars.DOCKERHUB_REPO }}
|
||||
repo: ${{ vars.DOCKERHUB_REPO }}
|
||||
tag: ${{ needs.check-version.outputs.oss }}
|
||||
release: true
|
||||
|
||||
@@ -290,7 +290,7 @@ jobs:
|
||||
with:
|
||||
edition: ee
|
||||
commit: ${{ inputs.commit }}
|
||||
repo: ${{ vars.DOCKERHUB_OWNER }}/${{ vars.DOCKERHUB_REPO }}-enterprise
|
||||
repo: ${{ vars.DOCKERHUB_REPO }}-enterprise
|
||||
tag: ${{ needs.check-version.outputs.ee }}
|
||||
release: true
|
||||
|
||||
@@ -330,6 +330,7 @@ jobs:
|
||||
get-extra-tags:
|
||||
needs: [ check-version ]
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
extra_tags: ${{ steps.taglist.outputs.extra_tags }}
|
||||
steps:
|
||||
|
||||
12
.github/workflows/report.update-e2e-timings.yml
vendored
@@ -56,6 +56,16 @@ jobs:
|
||||
|
||||
core.setOutput('artifact-count', result.artifactCount);
|
||||
|
||||
- uses: ./.github/actions/prepare-node-bun
|
||||
|
||||
- name: Upload e2e test timings to Stats
|
||||
if: ${{ github.repository == 'metabase/metabase' }}
|
||||
env:
|
||||
TIMINGS_PATH: e2e/support/timings.json
|
||||
ENG_STATS_URL: ${{ secrets.ENG_STATS_URL }}
|
||||
API_KEY: ${{ secrets.ENG_STATS_API_KEY }}
|
||||
run: bun .github/scripts/upload-e2e-test-timings.ts
|
||||
|
||||
- name: Configure git and commit changes
|
||||
id: commit
|
||||
run: |
|
||||
@@ -110,7 +120,7 @@ jobs:
|
||||
--body "$BODY" \
|
||||
--label ".CI & Tests" \
|
||||
--label "no-backport" \
|
||||
--reviewer metabase/devexp)
|
||||
--reviewer metabase/devex)
|
||||
|
||||
PR_NUMBER=${URL##*/}
|
||||
|
||||
|
||||
102
.github/workflows/run-tests.yml
vendored
@@ -57,11 +57,10 @@ jobs:
|
||||
build_scripts: ${{ steps.changes.outputs.build_scripts }}
|
||||
embedder_plugin: ${{ steps.changes.outputs.embedder_plugin }}
|
||||
frontend_all: ${{ steps.changes.outputs.frontend_all }}
|
||||
frontend_ci: ${{ steps.changes.outputs.frontend_ci }}
|
||||
frontend_sources: ${{ steps.changes.outputs.frontend_sources }}
|
||||
frontend_unit_infra: ${{ steps.changes.outputs.frontend_unit_infra }}
|
||||
frontend_loki_ci: ${{ steps.changes.outputs.frontend_loki_ci }}
|
||||
frontend_loki_infra: ${{ steps.changes.outputs.frontend_loki_infra }}
|
||||
frontend_loki_all: ${{ steps.changes.outputs.frontend_loki_all }}
|
||||
shared_sources: ${{ steps.changes.outputs.shared_sources }}
|
||||
documentation: ${{ steps.changes.outputs.documentation }}
|
||||
embedding_documentation: ${{ steps.changes.outputs.embedding_documentation }}
|
||||
@@ -69,6 +68,7 @@ jobs:
|
||||
embedding_sdk_host_sample_apps: ${{ steps.changes.outputs.embedding_sdk_host_sample_apps }}
|
||||
embedding_sdk_ci: ${{ steps.changes.outputs.embedding_sdk_ci }}
|
||||
custom_viz_all: ${{ steps.changes.outputs.custom_viz_all }}
|
||||
ci_scripts: ${{ steps.changes.outputs.ci_scripts }}
|
||||
project_backend_checks: ${{ steps.changes.outputs.project_backend_checks }}
|
||||
project_migration_checks: ${{ steps.changes.outputs.project_migration_checks }}
|
||||
project_ratchet_checks: ${{ steps.changes.outputs.project_ratchet_checks }}
|
||||
@@ -97,16 +97,14 @@ jobs:
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
create-test-plan:
|
||||
needs: files-changed
|
||||
needs: [should-run, files-changed]
|
||||
name: Create test plan
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
if: ${{ github.event_name == 'pull_request' && needs.should-run.outputs.verdict == 'defer' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
stats: ${{ steps.compute.outputs.stats }}
|
||||
fe_unit_specs_to_run: ${{ steps.compute.outputs.fe_unit_specs_to_run }}
|
||||
loki_stories_to_run: ${{ steps.compute.outputs.loki_stories_to_run }}
|
||||
e2e_specs_to_run: ${{ steps.compute.outputs.e2e_specs_to_run }}
|
||||
artifact-id: ${{ steps.compute.outputs.artifact-id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Create test plan
|
||||
@@ -152,10 +150,30 @@ jobs:
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.outputs.no_source_code_changes != 'true')
|
||||
uses: ./.github/workflows/uberjar.yml
|
||||
with:
|
||||
containerize: ${{ (github.event_name == 'push' && github.ref_name == 'master') || contains(github.event.pull_request.labels.*.name, 'build-docker-uberjar') }}
|
||||
secrets: inherit
|
||||
|
||||
containerize:
|
||||
needs: [uberjar]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.uberjar.result == 'success' &&
|
||||
(
|
||||
(github.event_name == 'push' && github.ref_name == 'master') ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'build-docker-uberjar'))
|
||||
)
|
||||
name: containerize (${{ matrix.edition }})
|
||||
strategy:
|
||||
matrix:
|
||||
edition: ['ee', 'oss']
|
||||
uses: ./.github/workflows/containerize-jar.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
build-args: |
|
||||
GIT_COMMIT_SHA=${{ github.event.pull_request.head.sha || github.sha }}
|
||||
commit: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
edition: ${{ matrix.edition }}
|
||||
release: false
|
||||
|
||||
# Compile CLJS in parallel with the uberjar and pass it to each e2e group.
|
||||
# The compile is doesn't depend on MB_EDITION
|
||||
e2e-cljs:
|
||||
@@ -242,6 +260,10 @@ jobs:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.backend_all != 'true' }}
|
||||
smoke-only: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'ci:run-semantic-search-tests')
|
||||
}}
|
||||
|
||||
app-db-tests:
|
||||
if: |
|
||||
@@ -272,7 +294,7 @@ jobs:
|
||||
force-run: ${{ needs.should-run.outputs.verdict == 'force-run' }}
|
||||
|
||||
frontend-tests:
|
||||
needs: [should-run, files-changed]
|
||||
needs: [should-run, files-changed, create-test-plan]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
@@ -283,6 +305,8 @@ jobs:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.frontend_all != 'true' }}
|
||||
# A skipped/failed planner supplies no artifact, so the suite runs in full.
|
||||
test-plan-artifact-id: ${{ needs.create-test-plan.result == 'success' && needs.create-test-plan.outputs.artifact-id || '' }}
|
||||
skip-lint: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.frontend_all != 'true' &&
|
||||
@@ -290,9 +314,12 @@ jobs:
|
||||
skip-custom-viz: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.custom_viz_all != 'true' }}
|
||||
skip-ci-scripts: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.ci_scripts != 'true' }}
|
||||
|
||||
loki-tests:
|
||||
needs: [should-run, files-changed]
|
||||
needs: [should-run, files-changed, create-test-plan]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
@@ -301,9 +328,9 @@ jobs:
|
||||
with:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.frontend_ci != 'true' &&
|
||||
needs.files-changed.outputs.frontend_sources != 'true' &&
|
||||
needs.files-changed.outputs.frontend_loki_ci != 'true' }}
|
||||
needs.files-changed.outputs.frontend_loki_all != 'true' }}
|
||||
# A skipped/failed planner supplies no artifact, so the suite runs in full.
|
||||
test-plan-artifact-id: ${{ needs.create-test-plan.result == 'success' && needs.create-test-plan.outputs.artifact-id || '' }}
|
||||
|
||||
e2e-tests:
|
||||
needs: [should-run, files-changed, uberjar]
|
||||
@@ -403,50 +430,3 @@ jobs:
|
||||
needs.files-changed.outputs.frontend_sources == 'true'
|
||||
uses: ./.github/workflows/bundle-size.yml
|
||||
secrets: inherit
|
||||
|
||||
pr-env:
|
||||
needs: [uberjar]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
contains(github.event.pull_request.labels.*.name, 'PR-Env') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'PR-Env-self-hosting') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'PR-Env-analytics-dev')
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: false
|
||||
|
||||
pr-env-self-hosting:
|
||||
needs: [uberjar]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
contains(github.event.pull_request.labels.*.name, 'PR-Env-self-hosting')
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: true
|
||||
analytics_dev_mode: false
|
||||
|
||||
pr-env-analytics-dev:
|
||||
needs: [uberjar]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
contains(github.event.pull_request.labels.*.name, 'PR-Env-analytics-dev')
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: true
|
||||
|
||||
pr-env-stats-dump:
|
||||
needs: [uberjar]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
contains(github.event.pull_request.labels.*.name, 'PR-Env-Stats-Dump')
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: false
|
||||
stats_dump: true
|
||||
|
||||
11
.github/workflows/schedule-patch.yml
vendored
@@ -14,14 +14,17 @@ jobs:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch patch release for the current and previous major
|
||||
- name: Dispatch patch release for the current and recent majors
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | # js
|
||||
// Nightly patches cover the current supported major and the one
|
||||
// before it. release-auto.yml releases one major per dispatch.
|
||||
const autoPatchVersionsCount = Number('${{ vars.AUTO_PATCH_VERSIONS_COUNT }}') || 0;
|
||||
const currentRelease = Number('${{ vars.CURRENT_VERSION }}');
|
||||
const majors = [currentRelease];
|
||||
// [currentRelease, currentRelease - 1, ..., currentRelease - autoPatchVersionsCount]
|
||||
const majors = Array.from(
|
||||
{ length: autoPatchVersionsCount + 1 },
|
||||
(_, i) => currentRelease - i,
|
||||
);
|
||||
|
||||
await Promise.all(majors.map(version =>
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
|
||||
48
.github/workflows/semantic-search.yml
vendored
@@ -6,32 +6,10 @@ on:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Run one semantic-search job on demand, against the ref you dispatch on, optionally under a
|
||||
# profiler. Everything else about the job -- services, matrix legs, test args, quarantine gating --
|
||||
# is whatever the normal CI run uses, because it is the same job.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suite:
|
||||
description: 'Which semantic-search job to run. Only this one runs; the others are skipped.'
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- h2
|
||||
- mariadb
|
||||
- mysql
|
||||
- postgres
|
||||
- appdb-mode
|
||||
tests:
|
||||
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase-enterprise.semantic-search.index-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
profile:
|
||||
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
|
||||
smoke-only:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: Only run Postgres Latest to do a quick resource-efficient sanity check, intended to be set for PRs.
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-semantic-search
|
||||
@@ -43,18 +21,10 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Both read by the test-driver action, which is what actually runs the tests. Set here rather than
|
||||
# passed as inputs to each job so that turning either on is one line, not one line per job.
|
||||
# Empty (and so off) on workflow_call, where neither input exists.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job runs the
|
||||
# whole list.
|
||||
PROFILE_TESTS: ${{ inputs.profile }}
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
semantic-search-tests-h2:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'h2') }}
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'h2') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -98,7 +68,7 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-mariadb:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'mariadb') }}
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'mariadb') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -163,7 +133,7 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-mysql:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'mysql') }}
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'mysql') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -235,7 +205,7 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-postgres:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'postgres') }}
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'postgres') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -250,6 +220,10 @@ jobs:
|
||||
junit-name: semantic-search-tests-postgres-latest
|
||||
postgres-image: postgres:latest
|
||||
pgvector-image: pgvector/pgvector:pg17
|
||||
exclude: >-
|
||||
${{ inputs.smoke-only
|
||||
&& fromJSON('[{"version":{"name":"Postgres 14.x with pgvector pg17"}}]')
|
||||
|| fromJSON('[]') }}
|
||||
job:
|
||||
- name: Semantic Search Tests
|
||||
build-static-viz: false
|
||||
@@ -302,7 +276,7 @@ jobs:
|
||||
# MB_PGVECTOR_DB_URL is deliberately unset. The dedicated-harness tests gate themselves off; the
|
||||
# appdb-pgvector-mode round trip (and the URL-independent unit tests) run for real.
|
||||
semantic-search-tests-appdb-mode:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'appdb-mode') }}
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'appdb-mode') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
|
||||
34
.github/workflows/test.bundle-load-stats.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: Bundle Load Stats
|
||||
|
||||
# Reuses the uberjar built on every master merge: boots it, signs in, loads the
|
||||
# app in a throttled headless Chrome across four network and CPU conditions, and
|
||||
# appends the times to eng-stats-importer.
|
||||
# app in a throttled headless Chrome across four network and CPU conditions and
|
||||
# two site locales, and appends the times to eng-stats-importer.
|
||||
#
|
||||
# The instance is blank on purpose. `index.html` is built from settings and the
|
||||
# signed-in user alone, so seeding content would not change a single byte of the
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
needs: [uberjar-built]
|
||||
if: ${{ needs.uberjar-built.outputs.found == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
HEAD_SHA: ${{ inputs.commit || github.event.workflow_run.head_sha }}
|
||||
MB_JETTY_PORT: "4000"
|
||||
@@ -67,6 +67,9 @@ jobs:
|
||||
# Loads per condition. The first is the cold reading and the rest give the
|
||||
# median, so this trades run time for a steadier number.
|
||||
RUNS: "8"
|
||||
# Measured in this order, so the translated reading comes from the same
|
||||
# state the instance is set up in.
|
||||
LOCALES: "de en"
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
@@ -114,19 +117,36 @@ jobs:
|
||||
env:
|
||||
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
run: |
|
||||
bun frontend/test/bench/matrix.ts "$SITE/" "$RUNS" \
|
||||
| tee artifacts/load-times.json
|
||||
# The site locale decides which translation catalogue the document
|
||||
# carries, and a user with no locale of their own falls back to it.
|
||||
# Measure both, so every commit has a translated reading and an
|
||||
# English one, and the difference is what translation costs.
|
||||
for locale in $LOCALES; do
|
||||
curl -sS -X PUT -o /dev/null \
|
||||
-H "X-Metabase-Session: ${SESSION_COOKIE}" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"value\":\"${locale}\"}" \
|
||||
"$SITE/api/setting/site-locale"
|
||||
# One request to absorb the settings cache miss the switch causes.
|
||||
curl -sS -o /dev/null "$SITE/"
|
||||
bun frontend/test/bench/matrix.ts "$SITE/" "$RUNS" \
|
||||
| tee "artifacts/load-times-${locale}.json"
|
||||
done
|
||||
- name: Show the Metabase log if something went wrong
|
||||
if: failure()
|
||||
run: tail -100 artifacts/metabase.log || true
|
||||
- name: Import load times into Stats
|
||||
if: ${{ inputs.commit == '' }}
|
||||
env:
|
||||
ROWS: artifacts/load-times.json
|
||||
# Passed via env (not interpolated into the shell) so the commit
|
||||
# message can't inject commands; the script keeps only the subject.
|
||||
COMMIT_MESSAGE: ${{ github.event.workflow_run.head_commit.message }}
|
||||
COMMIT_TIMESTAMP: ${{ github.event.workflow_run.head_commit.timestamp }}
|
||||
ENG_STATS_URL: ${{ secrets.ENG_STATS_URL }}
|
||||
API_KEY: ${{ secrets.ENG_STATS_API_KEY }}
|
||||
run: bun .github/scripts/upload-bundle-load-stats.ts
|
||||
run: |
|
||||
# Each file carries its own `Locale`, read from the served document.
|
||||
for locale in $LOCALES; do
|
||||
ROWS="artifacts/load-times-${locale}.json" \
|
||||
bun .github/scripts/upload-bundle-load-stats.ts
|
||||
done
|
||||
|
||||
65
.github/workflows/test.profile-backend.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Profile Backend
|
||||
|
||||
# The only on-demand entry point for the backend test suites. Dispatching this workflow runs one
|
||||
# job of one suite against the ref you dispatch on, under clj-async-profiler, and attaches a
|
||||
# flamegraph to the job.
|
||||
#
|
||||
# The suite workflows below are called unchanged: services, matrix legs, test args and quarantine
|
||||
# gating are whatever the normal CI run uses, because they are the same jobs. They declare nothing
|
||||
# for this feature -- they read `suite`, `db` and `tests` off `github.event.inputs`, which inside a
|
||||
# called workflow is this dispatch's payload.
|
||||
#
|
||||
# `suite` picks the workflow, `db` picks the leg within it. `db` is free text so that adding a
|
||||
# driver or an app-DB leg needs no edit here; an unknown driver fails the drivers run through
|
||||
# mage's `--only-driver`, and an unknown app-DB or semantic-search leg matches nothing and runs no
|
||||
# jobs.
|
||||
#
|
||||
# Renaming an input here means updating every reader -- `grep -rn github.event.inputs .github/`.
|
||||
# Nothing enforces that, deliberately: a stale name reads as empty, which runs every leg of the
|
||||
# suite unprofiled, on the run you are watching. It cannot affect a normal CI run, where those
|
||||
# references are empty either way.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suite:
|
||||
description: 'Which suite to profile.'
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- backend
|
||||
- app-db
|
||||
- drivers
|
||||
- semantic-search
|
||||
db:
|
||||
description: 'Which leg of the suite: app-db takes mariadb, mysql or postgres; drivers takes a driver name, e.g. bigquery or mongo-ssl; semantic-search takes h2, mariadb, mysql, postgres or appdb-mode. Ignored by backend. Blank runs the whole suite -- every leg for app-db and semantic-search, mage''s usual decisions for drivers.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
tests:
|
||||
description: 'Optional: profile only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank profiles the leg''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# These jobs gate on `inputs.suite` -- this workflow's own input. Only the called workflows reach
|
||||
# for `github.event.inputs.*`, because `inputs` there is their own `workflow_call` signature.
|
||||
jobs:
|
||||
backend:
|
||||
if: ${{ inputs.suite == 'backend' }}
|
||||
uses: ./.github/workflows/backend.yml
|
||||
secrets: inherit
|
||||
|
||||
app-db:
|
||||
if: ${{ inputs.suite == 'app-db' }}
|
||||
uses: ./.github/workflows/app-db.yml
|
||||
secrets: inherit
|
||||
|
||||
drivers:
|
||||
if: ${{ inputs.suite == 'drivers' }}
|
||||
uses: ./.github/workflows/drivers.yml
|
||||
secrets: inherit
|
||||
|
||||
semantic-search:
|
||||
if: ${{ inputs.suite == 'semantic-search' }}
|
||||
uses: ./.github/workflows/semantic-search.yml
|
||||
secrets: inherit
|
||||
18
.github/workflows/translation-update.yml
vendored
@@ -144,12 +144,20 @@ jobs:
|
||||
EOF
|
||||
)"
|
||||
|
||||
- name: Approve and Merge the PR
|
||||
run: |
|
||||
gh pr review --approve "$PR_NUMBER"
|
||||
gh pr merge --squash --auto "$PR_NUMBER"
|
||||
# Approving takes a different token than the one that opened the PR (above, the automation
|
||||
# user's): GitHub won't let a token approve its own pull request.
|
||||
- name: Approve the PR
|
||||
run: gh pr review --approve "$PR_NUMBER"
|
||||
shell: bash
|
||||
env:
|
||||
# uses a different token than the token that created the PR so we can approve and merge it
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.create_pr.outputs.pr_number }}
|
||||
|
||||
# Merging goes back through the automation user: GitHub starts no workflow run for a push made
|
||||
# with `GITHUB_TOKEN`, so an auto-merge enabled with it lands on master with no CI.
|
||||
- name: Merge the PR
|
||||
run: gh pr merge --squash --auto "$PR_NUMBER"
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.create_pr.outputs.pr_number }}
|
||||
|
||||
85
.github/workflows/uberjar.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Build Uberjar
|
||||
|
||||
# This workflow builds and containerizes an uberjar and pushes it to dockerhub.
|
||||
# This workflow builds the uberjar.
|
||||
|
||||
concurrency:
|
||||
group: uberjar-${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
@@ -14,11 +14,6 @@ on:
|
||||
type: string
|
||||
default: ''
|
||||
required: false
|
||||
containerize:
|
||||
description: "Set if the uberjar should be containerized and pushed to Dockerhub."
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
commit:
|
||||
@@ -26,11 +21,6 @@ on:
|
||||
type: string
|
||||
default: ''
|
||||
required: false
|
||||
containerize:
|
||||
description: "Set if the uberjar should be containerized and pushed to Dockerhub."
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
@@ -101,14 +91,15 @@ jobs:
|
||||
if: ${{ needs.setup.outputs.build_editions != '[]' }}
|
||||
name: build (${{ matrix.edition }})
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
edition: ${{ fromJson(needs.setup.outputs.build_editions) }}
|
||||
env:
|
||||
MB_EDITION: ${{ matrix.edition }}
|
||||
INTERACTIVE: false
|
||||
SKIP_LICENSES: ${{ github.event_name == 'pull_request' && !inputs.containerize }} # faster builds for dev
|
||||
# Faster builds for devs
|
||||
SKIP_LICENSES: ${{ github.event_name == 'pull_request' }}
|
||||
# Emit bundle stats on trunk/release builds (tracked over time) and on PRs
|
||||
# (the bundle-size check reads them for both the PR head and its base ref).
|
||||
EMIT_BUNDLE_STATS: ${{ (github.ref_name == 'master' || startsWith(github.ref_name, 'release-') || github.event_name == 'pull_request') && 'true' || 'false' }}
|
||||
@@ -135,6 +126,19 @@ jobs:
|
||||
name: metabase-bundle-stats-${{ matrix.edition }}-${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
path: target/bundle-stats
|
||||
if-no-files-found: warn
|
||||
- name: Verify license files are present in uberjar
|
||||
if: ${{ env.SKIP_LICENSES != 'true' }}
|
||||
run: |
|
||||
echo "Checking for license files in uberjar..."
|
||||
unzip -l ./target/uberjar/metabase.jar | grep -q "license-backend-third-party.txt" || (echo "ERROR: license-backend-third-party.txt not found in uberjar" && exit 1)
|
||||
unzip -l ./target/uberjar/metabase.jar | grep -q "license-frontend-third-party.txt" || (echo "ERROR: license-frontend-third-party.txt not found in uberjar" && exit 1)
|
||||
echo "Both license files found in uberjar"
|
||||
- name: Launch uberjar
|
||||
run: >-
|
||||
java --add-opens java.base/java.nio=ALL-UNNAMED -jar ./target/uberjar/metabase.jar &
|
||||
- name: Wait for Metabase to start
|
||||
timeout-minutes: 5
|
||||
run: while ! curl -s 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done
|
||||
- name: Mark with the commit hash
|
||||
run: git rev-parse --short HEAD > COMMIT-ID
|
||||
shell: bash
|
||||
@@ -145,62 +149,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# Same key the artifact lookups use, so a build for an explicitly dispatched commit
|
||||
# uploads where `setup` and `jar-healthcheck` go looking for it.
|
||||
# uploads where `setup` and the containerize workflow go looking for it.
|
||||
name: metabase-${{ matrix.edition }}-${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}-uberjar
|
||||
path: |
|
||||
./target/uberjar/metabase.jar
|
||||
./COMMIT-ID
|
||||
./SHA256.sum
|
||||
|
||||
jar-healthcheck:
|
||||
needs: [setup, build]
|
||||
# Health-check whatever we are about to containerize, plus every build on master and release.
|
||||
if: ${{ !failure() && !cancelled() && (inputs.containerize || github.ref_name == 'master' || startsWith(github.ref_name, 'release-')) }}
|
||||
name: jar-healthcheck (${{ matrix.edition }})
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
edition: [ee, oss]
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
- name: Prepare JRE (Java Run-time Environment)
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
java-package: jre
|
||||
java-version: ${{ needs.setup.outputs.java_version || 25 }}
|
||||
distribution: "temurin"
|
||||
- run: java -version
|
||||
- name: Retrieve uberjar artifact
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
edition: ${{ matrix.edition }}
|
||||
- name: Verify license files are present in uberjar
|
||||
run: |
|
||||
echo "Checking for license files in uberjar..."
|
||||
unzip -l ./metabase.jar | grep -q "license-backend-third-party.txt" || (echo "ERROR: license-backend-third-party.txt not found in uberjar" && exit 1)
|
||||
unzip -l ./metabase.jar | grep -q "license-frontend-third-party.txt" || (echo "ERROR: license-frontend-third-party.txt not found in uberjar" && exit 1)
|
||||
echo "Both license files found in uberjar"
|
||||
- name: Launch uberjar
|
||||
run: >-
|
||||
java --add-opens java.base/java.nio=ALL-UNNAMED -jar ./metabase.jar &
|
||||
- name: Wait for Metabase to start
|
||||
run: while ! curl 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done
|
||||
|
||||
containerize:
|
||||
needs: [build, jar-healthcheck]
|
||||
if: ${{ inputs.containerize && !failure() && !cancelled() }}
|
||||
name: containerize (${{ matrix.edition }})
|
||||
strategy:
|
||||
matrix:
|
||||
edition: [ee, oss]
|
||||
uses: ./.github/workflows/containerize-jar.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
edition: ${{ matrix.edition }}
|
||||
commit: ${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
release: false
|
||||
build-args: |
|
||||
GIT_COMMIT_SHA=${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
9
.gitignore
vendored
@@ -86,7 +86,11 @@ cypress.env.json
|
||||
/resources/frontend_client/app/**/*.hot-update.js
|
||||
/resources/frontend_client/app/**/*.hot-update.json
|
||||
/resources/frontend_client/app/dist/
|
||||
/resources/frontend_client/app/locales
|
||||
/resources/frontend_client/app/locales/*
|
||||
# Keep the directory itself: the frontend build resolves `locales/<locale>.json`
|
||||
# through it, and rspack fails on a context whose directory does not exist. The
|
||||
# `:translations` build step fills it in.
|
||||
!/resources/frontend_client/app/locales/.gitkeep
|
||||
/resources/frontend_client/app/embed.js
|
||||
/resources/frontend_client/app/embedding-sdk.js
|
||||
/resources/frontend_client/app/embedding-sdk.js.map
|
||||
@@ -210,3 +214,6 @@ __pycache__/
|
||||
# dependency-cruiser output
|
||||
dependency-graph.json
|
||||
.playwright-cli/
|
||||
|
||||
# mage modules-tree --html output
|
||||
/modules.html
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 573 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.8 KiB |