Compare commits
53 Commits
persistent
...
flargs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164caee2ee | ||
|
|
58f9f4550a | ||
|
|
5a70a6d94e | ||
|
|
aa6a0be593 | ||
|
|
d908f243b4 | ||
|
|
1e3f3617f8 | ||
|
|
188f9be1d1 | ||
|
|
b40465eef2 | ||
|
|
e1890313ab | ||
|
|
0a778c7c25 | ||
|
|
7db746cf04 | ||
|
|
1ffdd899a1 | ||
|
|
424aa3361c | ||
|
|
25d44affe6 | ||
|
|
5aff089633 | ||
|
|
27ebc7e874 | ||
|
|
83c254ac76 | ||
|
|
c29c0dc2a9 | ||
|
|
00a91d2974 | ||
|
|
770af0c170 | ||
|
|
ea7c79e2c3 | ||
|
|
135869854a | ||
|
|
8293580261 | ||
|
|
872d1de89b | ||
|
|
82bd36db7e | ||
|
|
0b9cbc15c4 | ||
|
|
9b29bcfe27 | ||
|
|
a64e53c93b | ||
|
|
4fd42c9580 | ||
|
|
503b174228 | ||
|
|
2bec3cf42b | ||
|
|
ce2a0deb44 | ||
|
|
85262dc373 | ||
|
|
d61e102bf1 | ||
|
|
953f7eb568 | ||
|
|
cd8294f54c | ||
|
|
fe36f9d6ca | ||
|
|
c810b7e0b2 | ||
|
|
4f28552eae | ||
|
|
068bcc4796 | ||
|
|
7c266cd942 | ||
|
|
3a431c8740 | ||
|
|
690c76d931 | ||
|
|
607cdfaa6e | ||
|
|
54dad9a3a3 | ||
|
|
1418b1aecd | ||
|
|
9508a9537e | ||
|
|
963e5996cf | ||
|
|
5e87520e72 | ||
|
|
8e3b098d3c | ||
|
|
6ab33034fc | ||
|
|
12e98c4a87 | ||
|
|
8b450792c7 |
@@ -318,8 +318,7 @@
|
||||
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
|
||||
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
|
||||
toucan2.core/debug {:message "Don't commit code using t2/debug"}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
|
||||
}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
|
||||
|
||||
:discouraged-namespace
|
||||
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
|
||||
@@ -583,6 +582,7 @@
|
||||
metabase.embedding-rest.api.embed-test/with-chain-filter-fixtures! hooks.common/let-one-with-optional-value
|
||||
metabase.embedding-rest.api.embed-test/with-temp-card hooks.common/let-one-with-optional-value
|
||||
metabase.embedding-rest.api.embed-test/with-temp-dashcard hooks.common/let-one-with-optional-value
|
||||
metabase.flargs.core/defflarg hooks.metabase.flargs.core/defflarg
|
||||
metabase.legacy-mbql.schema.macros/defclause hooks.metabase.legacy-mbql.schemas.macros/defclause
|
||||
metabase.legacy-mbql.schema.macros/defclause* hooks.metabase.legacy-mbql.schemas.macros/defclause*
|
||||
metabase.legacy-mbql.schema.macros/one-of hooks.metabase.legacy-mbql.schemas.macros/one-of
|
||||
|
||||
@@ -933,6 +933,15 @@
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
flargs
|
||||
;; The defflarg macro, registry, dispatcher, and the env-var-driven startup activation. Consumed
|
||||
;; by main-tree seams (routes, core.init) and by flarg-side code.
|
||||
{:team "DevEx"
|
||||
:api #{metabase.flargs.core
|
||||
metabase.flargs.init}
|
||||
:uses #{classloader
|
||||
util}}
|
||||
|
||||
formatter
|
||||
{:team "UX West"
|
||||
:api #{metabase.formatter.core}
|
||||
@@ -1765,7 +1774,7 @@
|
||||
api
|
||||
config
|
||||
embedding
|
||||
permissions ; FIXME
|
||||
permissions ; FIXME
|
||||
session
|
||||
settings
|
||||
users
|
||||
@@ -1905,7 +1914,7 @@
|
||||
:api #{metabase.session.api
|
||||
metabase.session.core
|
||||
metabase.session.init
|
||||
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
|
||||
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
|
||||
:uses #{api
|
||||
app-db
|
||||
auth-identity
|
||||
@@ -2560,6 +2569,18 @@
|
||||
:model/Segment
|
||||
:model/Table}}
|
||||
|
||||
flarg/joke-of-the-day
|
||||
{:team "UX West"
|
||||
:api #{}
|
||||
:uses #{api
|
||||
flargs
|
||||
util}}
|
||||
|
||||
flarg/test-flarg
|
||||
{:team "DevEx"
|
||||
:api #{}
|
||||
:uses #{flargs}}
|
||||
|
||||
enterprise/action-v2
|
||||
{:team "Gadget"
|
||||
:api #{metabase-enterprise.action-v2.api
|
||||
@@ -2944,7 +2965,6 @@
|
||||
premium-features}
|
||||
:model-imports #{:model/Card :model/Collection :model/Table}}
|
||||
|
||||
|
||||
enterprise/metabot
|
||||
{:team "Metabot"
|
||||
:api #{}
|
||||
@@ -2962,7 +2982,6 @@
|
||||
enterprise/transforms-python}
|
||||
:model-imports #{:model/Card :model/Transform}}
|
||||
|
||||
|
||||
enterprise/permission-debug
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.permission-debug.api}
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
(defn module
|
||||
"E.g.
|
||||
|
||||
(module 'metabase.qp.middleware.wow) => 'qp
|
||||
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
|
||||
(module 'metabase.qp.middleware.wow) => 'qp
|
||||
(module 'metabase-enterprise.whatever.core) => 'enterprise/whatever
|
||||
(module 'metabase.flarg.joke-of-the-day.api) => 'flarg/joke-of-the-day"
|
||||
[ns-symb]
|
||||
{:pre [(simple-symbol? ns-symb)]}
|
||||
;; treat something like `metabase.driver-test` (for a module that hasn't fully been updated to use `.core`
|
||||
@@ -27,6 +28,9 @@
|
||||
(or (some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
(symbol "enterprise"))
|
||||
(some->> (re-find #"^metabase\.flarg\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
(symbol "flarg"))
|
||||
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
symbol))))
|
||||
|
||||
52
.clj-kondo/src/hooks/metabase/flargs/core.clj
Normal file
52
.clj-kondo/src/hooks/metabase/flargs/core.clj
Normal file
@@ -0,0 +1,52 @@
|
||||
(ns hooks.metabase.flargs.core
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[hooks.common]))
|
||||
|
||||
(defn defflarg
|
||||
"Hook for [[metabase.flargs.core/defflarg]]. The macro shape is:
|
||||
|
||||
(defflarg fn-name docstring flarg-key flarg-ns arglist & body)
|
||||
|
||||
We rewrite to:
|
||||
|
||||
(let [_flarg-key flarg-key]
|
||||
(defn fn-name docstring arglist & body))
|
||||
|
||||
so that kondo:
|
||||
|
||||
- sees a function definition with the given name, docstring, arglist, and body;
|
||||
- treats `flarg-key` as a referenced value (no unused warnings);
|
||||
- performs normal arity checking at call sites based on `arglist`.
|
||||
|
||||
`flarg-ns` is dropped from the expansion entirely because it is a bare namespace
|
||||
symbol -- not something kondo can resolve as a var -- and appearing in the output
|
||||
would produce a spurious unresolved-symbol error. This mirrors how
|
||||
[[hooks.metabase.premium-features.defenterprise/defenterprise]] discards its
|
||||
enterprise-namespace positional arg."
|
||||
[{node :node}]
|
||||
(let [[_defflarg fn-name docstring flarg-key _flarg-ns & fn-tail] (:children node)]
|
||||
{:node (hooks/list-node
|
||||
(list
|
||||
(hooks/token-node 'let)
|
||||
(hooks/vector-node
|
||||
[(hooks/token-node '_flarg-key) flarg-key])
|
||||
(-> (hooks/list-node
|
||||
(concat
|
||||
(filter some?
|
||||
(list (hooks/token-node 'defn)
|
||||
fn-name
|
||||
docstring))
|
||||
fn-tail))
|
||||
(with-meta (meta node))
|
||||
hooks.common/add-lsp-ignore-unused-public-var-metadata)))}))
|
||||
|
||||
(comment
|
||||
;; Dev helper: pass a string form through the hook to see what it rewrites to.
|
||||
(defn- defflarg* [form-str]
|
||||
(hooks/sexpr
|
||||
(:node
|
||||
(defflarg {:node (hooks/parse-string form-str)}))))
|
||||
|
||||
(defflarg*
|
||||
"(defflarg test-fn \"Default: returns :default.\" :flarg/test-flarg metabase.flarg.test-flarg.core [] :default)"))
|
||||
6
.github/workflows/backend.yml
vendored
6
.github/workflows/backend.yml
vendored
@@ -122,7 +122,13 @@ jobs:
|
||||
|
||||
- name: "Test Java ${{ matrix.java-version }} ${{ matrix.job.name }}"
|
||||
id: run-java-tests
|
||||
# `bin/with-flargs` reads `.release-flags` (if present) and propagates it into the command
|
||||
# as RF_* env vars + `-A:flarg/*` alias args. On master the file is absent (enforced by the
|
||||
# merge-gate test in `metabase.flargs.release-flags-gate-test`) so this is a pass-through;
|
||||
# on a PR branch with `.release-flags` committed, the flargs it names are activated for
|
||||
# this test run. See FLARG.md § How `.release-flags` interacts with CI.
|
||||
run: >-
|
||||
bin/with-flargs
|
||||
clojure -X:dev:ci:test:${{ matrix.job.edition }}:${{ matrix.job.edition }}-dev
|
||||
:exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
${{ matrix.job.test-args }}
|
||||
|
||||
@@ -43,4 +43,4 @@ clean_output() {
|
||||
| sed $'s/\033[\[(][0-9;]*[A-Za-z]//g; s/\033[^[]*//g'
|
||||
}
|
||||
|
||||
exec clojure -X"${ALIASES}" "${args[@]}" 2>&1 | clean_output
|
||||
exec "$(dirname "$0")/with-flargs" clojure -X"${ALIASES}" "${args[@]}" 2>&1 | clean_output
|
||||
|
||||
99
bin/with-flargs
Executable file
99
bin/with-flargs
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrap a command with flarg activation from the repo-root `.release-flags` file.
|
||||
#
|
||||
# The `.release-flags` file is the source of truth for which flargs are enabled for a build.
|
||||
# Its presence on master is blocked by a merge-gate test; it lives transiently on PR branches
|
||||
# during feature development. See FLARG.md § Configuration.
|
||||
#
|
||||
# What this wrapper does:
|
||||
#
|
||||
# 1. If `.release-flags` is absent, exec the passed command unmodified (the master-clean path).
|
||||
# 2. If present, parse it as an EDN set of `:flarg/<name>` keywords.
|
||||
# 3. For each keyword:
|
||||
# - export RF_<UPPERCASE_UNDERSCORE>=true (the runtime activation env var read by
|
||||
# `metabase.flargs.init/activate!`)
|
||||
# - append `-A:flarg/<name>` to the command-line args (inserted after the first
|
||||
# `clojure` / `clj` token so the alias joins the rest of the alias chain)
|
||||
# 4. exec the modified command.
|
||||
#
|
||||
# The RF_* env var name and the `:flarg/<name>` alias are the INVERSE of the translation in
|
||||
# `metabase.flargs.init/env-key->flarg-keyword`:
|
||||
#
|
||||
# :flarg/joke-of-the-day <-> RF_JOKE_OF_THE_DAY <-> -A:flarg/joke-of-the-day
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# bin/with-flargs clojure -X:dev:ee:ee-dev:test ...
|
||||
# bin/with-flargs ./bin/test-agent :only '[metabase.foo-test]'
|
||||
# bin/with-flargs env # debug: see which RF_* vars would be exported
|
||||
#
|
||||
# If `.release-flags` contains `#{:flarg/joke-of-the-day}`, the first example becomes
|
||||
# equivalent to:
|
||||
#
|
||||
# RF_JOKE_OF_THE_DAY=true clojure -X:dev:ee:ee-dev:test:flarg/joke-of-the-day ...
|
||||
#
|
||||
# macOS bash 3.2-compatible (no mapfile, no read -d, no associative arrays).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Usage: bin/with-flargs <command> [args...]" >&2
|
||||
echo " Reads .release-flags (if present) and propagates it to the command as RF_* env vars" >&2
|
||||
echo " and -A:flarg/* aliases. If the file is absent, passes the command through unchanged." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
RELEASE_FLAGS_FILE=".release-flags"
|
||||
|
||||
if [[ ! -f "$RELEASE_FLAGS_FILE" ]]; then
|
||||
# No flargs configured - pass through untouched. This is the master-clean path.
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Extract :flarg/<name> keywords from the EDN set. The file is whitespace + comments + a single
|
||||
# `#{ ... }` literal; we don't parse EDN here, we just pluck keyword forms. Comments (`;` to
|
||||
# end-of-line) are stripped first. `grep -oE` extracts each match; we then trim the `:flarg/`
|
||||
# prefix and dedupe via `sort -u`. `|| true` keeps us alive when the file has no matches (an
|
||||
# empty `#{}` set is valid) — grep exits non-zero on no match under `set -e`.
|
||||
FLARG_NAMES=$(
|
||||
sed 's/;.*$//' "$RELEASE_FLAGS_FILE" \
|
||||
| { grep -oE ':flarg/[A-Za-z0-9_-]+' || true; } \
|
||||
| sed 's|^:flarg/||' \
|
||||
| sort -u
|
||||
)
|
||||
|
||||
if [[ -z "$FLARG_NAMES" ]]; then
|
||||
# File exists but declares no flargs (e.g. `#{}` or just comments). Still a pass-through.
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Build the alias chain `:flarg/a:flarg/b:...` (no leading colon — it follows the `-A` flag).
|
||||
# Export RF_<UPPERCASE_UNDERSCORE>=true for each. `:flarg/foo-bar` -> `RF_FOO_BAR`.
|
||||
ALIAS_ARG=""
|
||||
while IFS= read -r name; do
|
||||
[[ -z "$name" ]] && continue
|
||||
env_name="RF_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')"
|
||||
export "$env_name=true"
|
||||
ALIAS_ARG="${ALIAS_ARG}:flarg/${name}"
|
||||
done <<< "$FLARG_NAMES"
|
||||
|
||||
# Inject `-A<alias-chain>` right after the first `clojure` / `clj` token in argv. For any other
|
||||
# command (e.g. `bin/test-agent`, `env`), we don't inject — scripts that compose their own
|
||||
# clojure invocation internally should pick up the RF_* env vars and handle aliases themselves
|
||||
# (e.g. `bin/test-agent` appends its own alias chain).
|
||||
#
|
||||
# Detection rule: the first arg whose basename is `clojure` or `clj`.
|
||||
INJECTED=0
|
||||
REWRITTEN=()
|
||||
for arg in "$@"; do
|
||||
REWRITTEN+=("$arg")
|
||||
if [[ "$INJECTED" -eq 0 ]]; then
|
||||
base=$(basename -- "$arg")
|
||||
if [[ "$base" == "clojure" || "$base" == "clj" ]]; then
|
||||
REWRITTEN+=("-A${ALIAS_ARG}")
|
||||
INJECTED=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exec "${REWRITTEN[@]}"
|
||||
7
bun.lock
7
bun.lock
@@ -93,6 +93,7 @@
|
||||
"dayjs": "^1.11.18",
|
||||
"detect-package-manager": "^3.0.2",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"echarts": "6.0.0",
|
||||
"events": "^3.3.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
@@ -2792,7 +2793,7 @@
|
||||
|
||||
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
|
||||
|
||||
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
@@ -5618,6 +5619,8 @@
|
||||
|
||||
"@redocly/cli/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
|
||||
|
||||
"@redocly/cli/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||
|
||||
"@redocly/cli/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"@redocly/cli/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
@@ -6956,6 +6959,8 @@
|
||||
|
||||
"typedoc/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
||||
|
||||
"typescript-plugin-css-modules/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||
|
||||
"unbox-primitive/which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
"unified/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
|
||||
|
||||
17
deps.edn
17
deps.edn
@@ -60,7 +60,7 @@
|
||||
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
|
||||
{:mvn/version "1.0.1"} ; Snowplow analytics
|
||||
com.sun.mail/jakarta.mail ^:antq/exclude ; 2+ changes imports from javax.mail to jakarta.mail
|
||||
{:mvn/version "1.6.8"} ; SMTP lib, required by postal
|
||||
{:mvn/version "1.6.8"} ; SMTP lib, required by postal
|
||||
com.taoensso/encore {:mvn/version "3.158.0"} ; Utilities library (used by Nippy)
|
||||
com.taoensso/nippy {:mvn/version "3.6.0"} ; Fast serialization (i.e., GZIP) library for Clojure
|
||||
com.vladsch.flexmark/flexmark {:mvn/version "0.64.8"} ; Markdown parsing
|
||||
@@ -422,6 +422,21 @@
|
||||
:oss-dev
|
||||
{}
|
||||
|
||||
;; ---------------------------------------- flarg aliases ----------------------------------------
|
||||
;; See FLARG.md. Each :flarg/<name> alias adds exactly one flarg's paths to the classpath. Paths
|
||||
;; are entirely disjoint from the base classpath - a flarg never shadows main-tree code. Off by
|
||||
;; default; enable per-build via deps alias (local) or the .release-flags file (CI/PR Envs).
|
||||
;;
|
||||
:flarg/joke-of-the-day
|
||||
{:extra-paths ["release-flags/joke-of-the-day/src"
|
||||
"release-flags/joke-of-the-day/test"]}
|
||||
|
||||
;; :flarg/test-flarg is the test flarg used by metabase.flargs.integration-test to prove the
|
||||
;; defflarg machinery works under real classpath isolation. It stays in master permanently as
|
||||
;; the load-bearing end-to-end test of the design.
|
||||
:flarg/test-flarg
|
||||
{:extra-paths ["release-flags/test-flarg/src"]}
|
||||
|
||||
;; Generate BE documentation with
|
||||
;; clojure -M:marginalia
|
||||
:marginalia
|
||||
|
||||
@@ -43,20 +43,34 @@
|
||||
(for [file (.listFiles (io/file (str (.getAbsolutePath (project-root-directory)) "/modules/drivers")))]
|
||||
(io/file file "src")))
|
||||
|
||||
(mu/defn- release-flags-source-roots :- [:sequential (ms/InstanceOfClass java.io.File)]
|
||||
[]
|
||||
(let [root (io/file (str (.getAbsolutePath (project-root-directory)) "/release-flags"))]
|
||||
(if (.exists root)
|
||||
(for [file (.listFiles root)]
|
||||
(io/file file "src"))
|
||||
[])))
|
||||
|
||||
(mu/defn- find-source-files :- [:sequential (ms/InstanceOfClass java.io.File)]
|
||||
[]
|
||||
(mapcat ns.find/find-sources-in-dir
|
||||
(list* (source-root) (enterprise-source-root) (drivers-source-roots))))
|
||||
(concat [(source-root) (enterprise-source-root)]
|
||||
(drivers-source-roots)
|
||||
(release-flags-source-roots))))
|
||||
|
||||
(mu/defn- module :- [:maybe symbol?]
|
||||
"E.g.
|
||||
|
||||
(module 'metabase.qp.middleware.wow) => 'qp
|
||||
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
|
||||
(module 'metabase.qp.middleware.wow) => 'qp
|
||||
(module 'metabase-enterprise.whatever.core) => 'enterprise/whatever
|
||||
(module 'metabase.flarg.joke-of-the-day.api) => 'flarg/joke-of-the-day"
|
||||
[ns-symb :- simple-symbol?]
|
||||
(or (some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
(symbol "enterprise"))
|
||||
(some->> (re-find #"^metabase\.flarg\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
(symbol "flarg"))
|
||||
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
symbol)))
|
||||
|
||||
9
e2e/support/helpers/e2e-release-flag-helpers.ts
Normal file
9
e2e/support/helpers/e2e-release-flag-helpers.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ReleaseFlag } from "metabase-types/api";
|
||||
|
||||
export const setReleaseFlag = (flag: ReleaseFlag, value: boolean) =>
|
||||
cy.request({
|
||||
method: "PUT",
|
||||
url: "/api/release-flags",
|
||||
body: { [flag]: value },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
@@ -45,6 +45,7 @@ export * from "./e2e-permissions-helpers";
|
||||
export * from "./e2e-python-helpers";
|
||||
export * from "./e2e-qa-databases-helpers";
|
||||
export * from "./e2e-relative-date-picker-helpers";
|
||||
export * from "./e2e-release-flag-helpers";
|
||||
export * from "./e2e-remote-sync-helpers";
|
||||
export * from "./e2e-request-helpers";
|
||||
export * from "./e2e-search-helpers";
|
||||
|
||||
36
e2e/test/scenarios/admin-2/release-flags.cy.spec.ts
Normal file
36
e2e/test/scenarios/admin-2/release-flags.cy.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const { H } = cy;
|
||||
|
||||
describe("Admin - Release Flags", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
cy.intercept("GET", "/api/session/properties").as("getSessionProperties");
|
||||
});
|
||||
|
||||
it("should allow toggling a release flag", () => {
|
||||
cy.visit("/");
|
||||
cy.wait("@getSessionProperties");
|
||||
cy.findByTestId("home-page").should("be.visible");
|
||||
cy.findByTestId("joke-of-the-day").should("not.exist");
|
||||
cy.request({
|
||||
method: "GET",
|
||||
url: "/api/joke-of-the-day",
|
||||
failOnStatusCode: false,
|
||||
}).then(({ status }) => {
|
||||
expect(status).to.equal(404);
|
||||
});
|
||||
|
||||
H.setReleaseFlag("joke-of-the-day", true);
|
||||
cy.visit("/");
|
||||
cy.wait("@getSessionProperties");
|
||||
cy.findByTestId("home-page").should("be.visible");
|
||||
cy.findByTestId("joke-of-the-day").should("be.visible");
|
||||
cy.request({
|
||||
method: "GET",
|
||||
url: "/api/joke-of-the-day",
|
||||
failOnStatusCode: false,
|
||||
}).then(({ status }) => {
|
||||
expect(status).to.equal(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
frontend/src/metabase/api/joke.ts
Normal file
14
frontend/src/metabase/api/joke.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Api } from "./api";
|
||||
|
||||
export const jokeApi = Api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getJoke: builder.query<{ setup: string; punchline: string }, void>({
|
||||
query: () => ({
|
||||
method: "GET",
|
||||
url: "/api/joke-of-the-day",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetJokeQuery } = jokeApi;
|
||||
@@ -4,7 +4,9 @@ import { t } from "ttag";
|
||||
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import { useHomepageDashboard } from "metabase/common/hooks/use-homepage-dashboard";
|
||||
import { JokeOfTheDay } from "metabase/jokes";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { hasReleaseFlag } from "metabase/lib/release-flags";
|
||||
import { updateUserSetting } from "metabase/redux/settings";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import { getHasDismissedCustomHomePageToast } from "metabase/selectors/app";
|
||||
@@ -14,12 +16,14 @@ import { HomeLayout } from "../HomeLayout";
|
||||
|
||||
export const HomePage = (): JSX.Element => {
|
||||
const { isLoadingDash } = useDashboardRedirect();
|
||||
|
||||
if (isLoadingDash) {
|
||||
return <LoadingAndErrorWrapper loading={isLoadingDash} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeLayout>
|
||||
{hasReleaseFlag("joke-of-the-day") && <JokeOfTheDay />}
|
||||
<HomeContent />
|
||||
</HomeLayout>
|
||||
);
|
||||
|
||||
27
frontend/src/metabase/jokes/JokeOfTheDay.tsx
Normal file
27
frontend/src/metabase/jokes/JokeOfTheDay.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useGetJokeQuery } from "metabase/api/joke";
|
||||
import { Box, Card, Flex, Text, Title } from "metabase/ui";
|
||||
|
||||
export function JokeOfTheDay() {
|
||||
const { data: joke, isLoading } = useGetJokeQuery();
|
||||
|
||||
if (!joke || isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="lg"
|
||||
my="lg"
|
||||
bd="1px solid var(--mb-color-border)"
|
||||
data-testid="joke-of-the-day"
|
||||
>
|
||||
<Flex align="center" gap="lg">
|
||||
<Title order={1}>😂</Title>
|
||||
<Box>
|
||||
<Text fw="bold">{joke.setup}</Text>
|
||||
<Text>{joke.punchline}</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1
frontend/src/metabase/jokes/index.ts
Normal file
1
frontend/src/metabase/jokes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./JokeOfTheDay";
|
||||
13
frontend/src/metabase/lib/release-flags.ts
Normal file
13
frontend/src/metabase/lib/release-flags.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type ReleaseFlag = "joke-of-the-day";
|
||||
|
||||
export type ReleaseFlagMap = Record<ReleaseFlag, boolean>;
|
||||
|
||||
const MetabaseReleaseFlags: ReleaseFlagMap =
|
||||
(process.env.RELEASE_FLAGS as unknown as ReleaseFlagMap) ?? {};
|
||||
|
||||
export const hasReleaseFlag = (flag: ReleaseFlag): boolean => {
|
||||
return MetabaseReleaseFlags?.[flag];
|
||||
};
|
||||
|
||||
// hang this on the window so we can call it in clojureScript
|
||||
window.hasReleaseFlag = hasReleaseFlag;
|
||||
65
frontend/src/types/global.d.ts
vendored
65
frontend/src/types/global.d.ts
vendored
@@ -1,35 +1,38 @@
|
||||
interface Window {
|
||||
MetabaseBootstrap: any;
|
||||
MetabaseRoot?: string;
|
||||
MetabaseNonce?: string;
|
||||
MetabaseUserColorScheme?: string;
|
||||
import type { ReleaseFlag } from "metabase/lib/release-flags";
|
||||
declare global {
|
||||
interface Window {
|
||||
MetabaseBootstrap: any;
|
||||
hasReleaseFlag?: (flag: ReleaseFlag) => boolean;
|
||||
MetabaseRoot?: string;
|
||||
MetabaseNonce?: string;
|
||||
MetabaseUserColorScheme?: string;
|
||||
|
||||
overrideIsWithinIframe?: boolean; // Mock that we're embedding, so we could test embed components
|
||||
METABASE?: boolean; // Add a global so we can check if the parent iframe is Metabase
|
||||
overrideIsWithinIframe?: boolean; // Mock that we're embedding, so we could test embed components
|
||||
METABASE?: boolean; // Add a global so we can check if the parent iframe is Metabase
|
||||
|
||||
// Make iFrameResizer available so that embed users can
|
||||
// have their embeds autosize to their content
|
||||
iFrameResizer?: {
|
||||
autoResize?: boolean;
|
||||
heightCalculationMethod?: string;
|
||||
onReady?: () => void;
|
||||
};
|
||||
// Make iFrameResizer available so that embed users can
|
||||
// have their embeds autosize to their content
|
||||
iFrameResizer?: {
|
||||
autoResize?: boolean;
|
||||
heightCalculationMethod?: string;
|
||||
onReady?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
// This allows importing static SVGs from TypeScript files
|
||||
declare module "*.svg" {
|
||||
const content: any;
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default content;
|
||||
}
|
||||
|
||||
// This allows importing CSS from TypeScript files
|
||||
declare module "*.css" {
|
||||
const classes: { [key: string]: string };
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default classes;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows importing static SVGs from TypeScript files
|
||||
declare module "*.svg" {
|
||||
const content: any;
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default content;
|
||||
}
|
||||
|
||||
// This allows importing CSS from TypeScript files
|
||||
declare module "*.css" {
|
||||
const classes: { [key: string]: string };
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module "iframe-resizer/js/iframeResizer.contentWindow.js";
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
export {};
|
||||
|
||||
1
frontend/src/types/iframe-resizer.d.ts
vendored
Normal file
1
frontend/src/types/iframe-resizer.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "iframe-resizer/js/iframeResizer.contentWindow.js";
|
||||
@@ -98,6 +98,7 @@
|
||||
"dayjs": "^1.11.18",
|
||||
"detect-package-manager": "^3.0.2",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"echarts": "6.0.0",
|
||||
"events": "^3.3.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
@@ -441,7 +442,7 @@
|
||||
"build-hot": "rm -f target/cljs_dev/cljs.core.js && bun run build:cljs && bun run concurrently -n 'js' -c 'green,cyan' 'bun run build-hot:js'",
|
||||
"build-hot-be": "rm -f target/cljs_dev/cljs.core.js && bun run concurrently -n 'cljs,js' -c 'yellow,green,cyan' 'bun run build-hot:cljs' 'ENABLE_CLJS_HOT_RELOAD=true bun run build-hot:js-wait'",
|
||||
"build-hot:cljs": "bun install && shadow-cljs watch app",
|
||||
"build-hot:js": "bun install && bun run clean-dev:js && NODE_OPTIONS=--max-old-space-size=8192 WEBPACK_BUNDLE=hot rspack serve",
|
||||
"build-hot:js": "bun install && bun run clean-dev:js && NODE_OPTIONS='--max-old-space-size=8192' WEBPACK_BUNDLE=hot rspack serve",
|
||||
"build-hot:js-wait": "bun run wait:cljs; bun run build-hot:js",
|
||||
"build-pure:cljs": "bun run clean-dev:cljs && shadow-cljs compile app",
|
||||
"build-release": "bun run build-release:cljs && bun run build-release:js",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
(ns metabase.flarg.joke-of-the-day.api
|
||||
"/api/joke-of-the-day endpoint. Only on the classpath when the `:flarg/joke-of-the-day` alias is
|
||||
active — classpath presence is the gate, so no runtime release-flag check is needed here."
|
||||
(:require
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.flarg.joke-of-the-day.jokes :as jokes]))
|
||||
|
||||
(api.macros/defendpoint :get "/" :- [:map
|
||||
["id" :int]
|
||||
["type" :string]
|
||||
["setup" :string]
|
||||
["punchline" :string]]
|
||||
"Return the joke of the day."
|
||||
[]
|
||||
(rand-nth (jokes/jokes)))
|
||||
@@ -0,0 +1,6 @@
|
||||
(ns metabase.flarg.joke-of-the-day.init
|
||||
"Loads the joke-of-the-day flarg's namespaces so its defflarg impls register and any
|
||||
side-effecting forms fire."
|
||||
(:require
|
||||
[metabase.flarg.joke-of-the-day.api]
|
||||
[metabase.flarg.joke-of-the-day.routes]))
|
||||
@@ -0,0 +1,9 @@
|
||||
(ns metabase.flarg.joke-of-the-day.jokes
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(defn jokes
|
||||
"Returns the collection of jokes from jokes.json."
|
||||
[]
|
||||
(json/decode (slurp (io/resource "jokes.json"))))
|
||||
@@ -0,0 +1,14 @@
|
||||
(ns metabase.flarg.joke-of-the-day.routes
|
||||
"Flarg-side impl of the [[metabase.api-routes.routes/joke-of-the-day-routes]] seam. Only on the
|
||||
classpath when the `:flarg/joke-of-the-day` alias is active. Registering this impl causes the
|
||||
main-side [[metabase.api-routes.routes/joke-of-the-day-routes]] dispatcher to return the route
|
||||
map entry that mounts `metabase.flarg.joke-of-the-day.api` under `/joke-of-the-day`."
|
||||
(:require
|
||||
[metabase.flargs.core :as flargs]))
|
||||
|
||||
(flargs/defflarg joke-of-the-day-routes
|
||||
"Route contribution for the joke-of-the-day flarg."
|
||||
:flarg/joke-of-the-day
|
||||
metabase.flarg.joke-of-the-day.routes
|
||||
[]
|
||||
{"/joke-of-the-day" 'metabase.flarg.joke-of-the-day.api})
|
||||
@@ -0,0 +1,24 @@
|
||||
(ns metabase.flarg.joke-of-the-day.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.flarg.joke-of-the-day.jokes :as jokes]
|
||||
[metabase.test.http-client :as client]))
|
||||
|
||||
(defn- joke-set
|
||||
"The jokes from jokes.json, with keyword keys so they compare equal to a parsed HTTP response."
|
||||
[]
|
||||
(set (map #(update-keys % keyword) (jokes/jokes))))
|
||||
|
||||
(deftest ^:parallel joke-of-the-day-endpoint-test
|
||||
(testing "GET /api/joke-of-the-day"
|
||||
;; The expected status of 200 covers both happy-path success and the no-auth
|
||||
;; requirement: [[client/client]] sends no credentials, so a 200 response proves
|
||||
;; the endpoint doesn't sit behind [[metabase.api.routes.common/+auth]].
|
||||
(let [response (client/client :get 200 "joke-of-the-day")]
|
||||
(testing "response matches the endpoint's schema"
|
||||
(is (int? (:id response)))
|
||||
(is (string? (:type response)))
|
||||
(is (string? (:setup response)))
|
||||
(is (string? (:punchline response))))
|
||||
(testing "returned joke is one of the jokes in jokes.json"
|
||||
(is (contains? (joke-set) response))))))
|
||||
@@ -0,0 +1,16 @@
|
||||
(ns metabase.flarg.joke-of-the-day.jokes-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.flarg.joke-of-the-day.jokes :as jokes]))
|
||||
|
||||
(deftest jokes-test
|
||||
(testing "jokes returns a non-empty collection"
|
||||
(let [result (jokes/jokes)]
|
||||
(is (seq result))
|
||||
(is (sequential? result))))
|
||||
(testing "each joke has the expected keys"
|
||||
(doseq [joke (jokes/jokes)]
|
||||
(is (contains? joke "id"))
|
||||
(is (contains? joke "type"))
|
||||
(is (contains? joke "setup"))
|
||||
(is (contains? joke "punchline")))))
|
||||
@@ -0,0 +1,17 @@
|
||||
(ns metabase.flarg.test-flarg.core
|
||||
"Flarg-side impl of the [[metabase.flargs.integration-seam/test-fn]] seam. Only on the classpath
|
||||
when the `:flarg/test-flarg` alias is active.
|
||||
|
||||
Registering its impl (via the `defflarg` form below) is what proves the end-to-end machinery
|
||||
works: classpath presence of this ns causes the seam's dispatcher to route to `:impl` instead of
|
||||
`:default`."
|
||||
(:require
|
||||
[metabase.flargs.core :as flargs]))
|
||||
|
||||
(flargs/defflarg test-fn
|
||||
"Flarg-side impl: returns `:impl`. Verifies that when the flarg ns is on the classpath, the
|
||||
dispatcher routes calls to the flarg impl rather than the main-side default."
|
||||
:flarg/test-flarg
|
||||
metabase.flarg.test-flarg.core
|
||||
[]
|
||||
:impl)
|
||||
@@ -0,0 +1,6 @@
|
||||
(ns metabase.flarg.test-flarg.init
|
||||
"Loads the test-flarg's source so its `defflarg` impls register. Phase 1.6 will wire up a startup
|
||||
hook that requires this ns; for now it just establishes the convention that every flarg has an
|
||||
`init` ns responsible for triggering registration."
|
||||
(:require
|
||||
[metabase.flarg.test-flarg.core]))
|
||||
62
resources/jokes.json
Normal file
62
resources/jokes.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"type": "general",
|
||||
"setup": "What did the fish say when it hit the wall?",
|
||||
"punchline": "Dam."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "general",
|
||||
"setup": "How do you make a tissue dance?",
|
||||
"punchline": "You put a little boogie on it."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "general",
|
||||
"setup": "What's Forrest Gump's password?",
|
||||
"punchline": "1Forrest1"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "general",
|
||||
"setup": "What do you call a belt made out of watches?",
|
||||
"punchline": "A waist of time."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "general",
|
||||
"setup": "Why can't bicycles stand on their own?",
|
||||
"punchline": "They are two tired"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "general",
|
||||
"setup": "How does a train eat?",
|
||||
"punchline": "It goes chew, chew"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "general",
|
||||
"setup": "What do you call a singing Laptop",
|
||||
"punchline": "A Dell"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "general",
|
||||
"setup": "How many lips does a flower have?",
|
||||
"punchline": "Tulips"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "general",
|
||||
"setup": "How do you organize an outer space party?",
|
||||
"punchline": "You planet"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "general",
|
||||
"setup": "What kind of shoes does a thief wear?",
|
||||
"punchline": "Sneakers"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
databaseChangeLog:
|
||||
- objectQuotingStrategy: QUOTE_ALL_OBJECTS
|
||||
|
||||
- changeSet:
|
||||
id: vRF.2026-04-01T00:00:00
|
||||
author: iethree
|
||||
context: rf-joke-of-the-day
|
||||
comment: Create jokes table
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: jokes
|
||||
remarks: stores jokes for the joke of the day feature
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
remarks: PK
|
||||
type: int
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: setup
|
||||
type: ${text.type}
|
||||
remarks: setup for the joke
|
||||
- column:
|
||||
name: punchline
|
||||
type: ${text.type}
|
||||
remarks: punchline for the joke
|
||||
@@ -1,6 +1,8 @@
|
||||
// @ts-check
|
||||
/* eslint-env node */
|
||||
|
||||
/* eslint-disable depend/ban-dependencies -- it is prohibitively difficult to load the env file for all scripts that call rspack */
|
||||
require("dotenv").config();
|
||||
const fs = require("fs");
|
||||
|
||||
const rspack = require("@rspack/core");
|
||||
@@ -94,6 +96,17 @@ class OnScriptError {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Record<string, boolean>} */
|
||||
const initialFlags = {};
|
||||
const ReleaseFlags = Object.entries(process.env)
|
||||
.filter(([key]) => key.startsWith("RF_"))
|
||||
.reduce((flags /** @type Record<string, boolean> **/, [key, value]) => {
|
||||
const flagName = key.slice(3).toLowerCase().replace(/_/g, "-");
|
||||
flags[flagName] = value === "true";
|
||||
return flags;
|
||||
}, initialFlags);
|
||||
|
||||
|
||||
/** @type {import('@rspack/cli').Configuration} */
|
||||
const config = {
|
||||
mode: isDevMode ? "development" : "production",
|
||||
@@ -297,6 +310,9 @@ const config = {
|
||||
MB_LOG_ANALYTICS: "false",
|
||||
ENABLE_CLJS_HOT_RELOAD: process.env.ENABLE_CLJS_HOT_RELOAD ?? "false",
|
||||
}),
|
||||
new rspack.DefinePlugin({
|
||||
"process.env.RELEASE_FLAGS": JSON.stringify(ReleaseFlags),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[metabase.documents.api]
|
||||
[metabase.eid-translation.api]
|
||||
[metabase.embedding-rest.api]
|
||||
[metabase.flargs.core :as flargs]
|
||||
[metabase.frontend-errors.api]
|
||||
[metabase.geojson.api]
|
||||
[metabase.glossary.api]
|
||||
@@ -151,8 +152,15 @@
|
||||
;;; !! Please read https://metaboat.slack.com/archives/CKZEMT1MJ/p1738972144181069 for more info !!
|
||||
;;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
(flargs/defflarg joke-of-the-day-routes
|
||||
"Route map entries contributed by :flarg/joke-of-the-day. Empty when flarg is off."
|
||||
:flarg/joke-of-the-day
|
||||
metabase.flarg.joke-of-the-day.routes
|
||||
[]
|
||||
{})
|
||||
|
||||
;;; ↓↓↓ KEEP THIS SORTED OR ELSE! ↓↓↓
|
||||
(def ^:private route-map
|
||||
(def ^:private base-route-map
|
||||
{"/action" (+auth 'metabase.actions-rest.api)
|
||||
"/activity" (+auth 'metabase.activity-feed.api)
|
||||
"/agent" metabase.agent-api.api/routes
|
||||
@@ -225,6 +233,11 @@
|
||||
"/util" 'metabase.api.util})
|
||||
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑
|
||||
|
||||
(def ^:private route-map
|
||||
"The full route map, composed from the base map plus any flarg-contributed entries."
|
||||
(merge base-route-map
|
||||
(joke-of-the-day-routes)))
|
||||
|
||||
;;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
;;; !! !!
|
||||
;;; !! DO NOT ADD `metabase.api.*` NAMESPACES THAT CONTAIN ENDPOINTS !!
|
||||
|
||||
@@ -104,7 +104,9 @@
|
||||
(not (str/starts-with? latest-migration "v"))
|
||||
changelog-legacy-file
|
||||
|
||||
(< (->> latest-migration (re-find #"v(\d+)\..*") second parse-long) 45)
|
||||
;; If the ID starts with "v" but doesn't match the versioned pattern (e.g., a release-flag
|
||||
;; migration ID), some-> short-circuits to nil (falsy) and we fall through to :else.
|
||||
(some-> (re-find #"v(\d+)\..*" latest-migration) second parse-long (< 45))
|
||||
changelog-legacy-file
|
||||
|
||||
:else
|
||||
@@ -191,16 +193,20 @@
|
||||
(.toString writer)))
|
||||
|
||||
(defn unrun-migrations
|
||||
"Returns a list of unrun migrations.
|
||||
"Returns a list of unrun migrations for the given `contexts` (default: all non-RF migrations).
|
||||
|
||||
It's a good idea to check to make sure there's actually something to do before running `(migrate :up)` so we can
|
||||
skip creating and releasing migration locks, which is both slightly dangerous and a waste of time when we won't be
|
||||
using them.
|
||||
|
||||
IMPORTANT: this function takes `data-source` but not `liquibase` because `.listUnrunChangeSets` is buggy. See #38257."
|
||||
[^DataSource data-source]
|
||||
(with-liquibase [liquibase data-source]
|
||||
(.listUnrunChangeSets liquibase nil (LabelExpression.))))
|
||||
([^DataSource data-source]
|
||||
;; Contexts("default") causes Liquibase to run changesets with *no* context (all normal migrations)
|
||||
;; but skip changesets that have an explicit context (i.e. rf-* release-flag changesets).
|
||||
(unrun-migrations data-source (Contexts. "default")))
|
||||
([^DataSource data-source ^Contexts contexts]
|
||||
(with-liquibase [liquibase data-source]
|
||||
(.listUnrunChangeSets liquibase contexts (LabelExpression.)))))
|
||||
|
||||
(defn- migration-lock-exists?
|
||||
"Is a migration lock in place for `liquibase`?"
|
||||
@@ -373,8 +379,8 @@
|
||||
(let [to-run-migrations (unrun-migrations data-source)
|
||||
unrun-migrations-count (count to-run-migrations)]
|
||||
(if (pos? unrun-migrations-count)
|
||||
(let [^Contexts contexts nil
|
||||
timer (u/start-timer)]
|
||||
(let [contexts (Contexts. "default")
|
||||
timer (u/start-timer)]
|
||||
(log/infof "Running %s migrations ..." unrun-migrations-count)
|
||||
(doseq [^ChangeSet change to-run-migrations]
|
||||
(log/tracef "To run migration %s" (.getId change)))
|
||||
@@ -388,14 +394,15 @@
|
||||
([liquibase]
|
||||
(update-with-change-log liquibase {}))
|
||||
([^Liquibase liquibase
|
||||
{:keys [^List change-set-filters exec-listener]
|
||||
:or {change-set-filters []}}]
|
||||
{:keys [^List change-set-filters exec-listener ^Contexts contexts]
|
||||
:or {change-set-filters []
|
||||
contexts (Contexts. "default")}}]
|
||||
(assert-locked liquibase)
|
||||
(let [change-log (.getDatabaseChangeLog liquibase)
|
||||
database (.getDatabase liquibase)
|
||||
log-iterator (ChangeLogIterator. change-log ^"[Lliquibase.changelog.filter.ChangeSetFilter;" (into-array ChangeSetFilter change-set-filters))
|
||||
update-visitor (UpdateVisitor. database ^ChangeExecListener exec-listener)
|
||||
runtime-env (RuntimeEnvironment. database (Contexts.) nil)]
|
||||
runtime-env (RuntimeEnvironment. database contexts nil)]
|
||||
(.run ^ChangeLogIterator log-iterator update-visitor runtime-env))))
|
||||
|
||||
(mu/defn force-migrate-up-if-needed!
|
||||
@@ -447,6 +454,44 @@
|
||||
(doseq [[^ChangeSet change-set fail-on-error?] fail-on-errors]
|
||||
(.setFailOnError change-set fail-on-error?))))))))
|
||||
|
||||
;;; ---- Release flag migrations ----
|
||||
|
||||
(defn- enabled-rf-contexts
|
||||
"Returns a seq of Liquibase context name strings for each enabled RF_ environment variable.
|
||||
Only reads from the OS environment — not JVM system properties or app-defaults — so migrations
|
||||
cannot be accidentally enabled via other config mechanisms.
|
||||
|
||||
RF_JOKE_OF_THE_DAY=true -> context \"rf-joke-of-the-day\""
|
||||
[]
|
||||
(seq (for [[k v] (System/getenv)
|
||||
:when (and (str/starts-with? k "RF_")
|
||||
(= "true" (str/lower-case (str/trim v))))]
|
||||
(-> k (subs 3) str/lower-case (str/replace "_" "-") (->> (str "rf-"))))))
|
||||
|
||||
(defn run-release-flag-migrations-if-needed!
|
||||
"Run any unrun release-flag migrations for each enabled RF_ environment variable.
|
||||
|
||||
Release-flag changesets live in 'migrations/release-flags/' alongside normal migrations and are
|
||||
protected by a Liquibase context matching their flag name. Normal updates pass Contexts(\"default\")
|
||||
which skips any changeset with an explicit context, so RF changesets never run accidentally.
|
||||
|
||||
Naming convention: a changeset must declare 'context: rf-<flag-name>' where <flag-name> is the
|
||||
part of the env var after 'RF_', lowercased with underscores replaced by dashes.
|
||||
|
||||
Example: RF_JOKE_OF_THE_DAY=true runs all changesets with 'context: rf-joke-of-the-day'."
|
||||
[^Liquibase liquibase ^DataSource data-source]
|
||||
(doseq [ctx (enabled-rf-contexts)]
|
||||
(let [contexts (Contexts. ctx)]
|
||||
;; Use a fresh Liquibase instance for the pre-lock check to avoid the .listUnrunChangeSets
|
||||
;; caching bug. See #38257. .update is idempotent so there is no correctness risk here.
|
||||
(when (seq (unrun-migrations data-source contexts))
|
||||
(log/infof "Running release-flag migrations for context: %s" ctx)
|
||||
(with-scope-locked liquibase
|
||||
(.update liquibase contexts)
|
||||
(log/infof "Completed release-flag migrations for %s" ctx))))))
|
||||
|
||||
;;; ---- Legacy / consolidated migrations ----
|
||||
|
||||
(def ^:private legacy-migrations-file "migrations/000_legacy_migrations.yaml")
|
||||
(def ^:private update001-migrations-file "migrations/001_update_migrations.yaml")
|
||||
|
||||
|
||||
@@ -69,7 +69,11 @@
|
||||
* `:print` - Just print the SQL for running the migrations, don't actually run them.
|
||||
* `:release-locks` - Manually release migration locks left by an earlier failed migration.
|
||||
(This shouldn't be necessary now that we run migrations inside a transaction, but is
|
||||
available just in case)."
|
||||
available just in case).
|
||||
|
||||
After running standard migrations, also runs any pending release-flag migrations from
|
||||
'migrations/release-flags/' for each enabled RF_ environment variable.
|
||||
See [[liquibase/run-release-flag-migrations-if-needed!]] for details."
|
||||
[data-source :- (ms/InstanceOfClass javax.sql.DataSource)
|
||||
direction :- :keyword
|
||||
& args]
|
||||
@@ -89,8 +93,10 @@
|
||||
|
||||
(log/info "Liquibase is ready.")
|
||||
(case direction
|
||||
:up (liquibase/migrate-up-if-needed! liquibase data-source)
|
||||
:force (liquibase/force-migrate-up-if-needed! liquibase data-source)
|
||||
:up (do (liquibase/migrate-up-if-needed! liquibase data-source)
|
||||
(liquibase/run-release-flag-migrations-if-needed! liquibase data-source))
|
||||
:force (do (liquibase/force-migrate-up-if-needed! liquibase data-source)
|
||||
(liquibase/run-release-flag-migrations-if-needed! liquibase data-source))
|
||||
:down (apply liquibase/rollback-major-version! conn liquibase false args)
|
||||
:down-force (apply liquibase/rollback-major-version! conn liquibase true args)
|
||||
:print (print-migrations-and-quit-if-needed! liquibase data-source)
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
[metabase.eid-translation.init]
|
||||
[metabase.embedding.init]
|
||||
[metabase.events.init]
|
||||
[metabase.flargs.init :as flargs.init]
|
||||
[metabase.geojson.init]
|
||||
[metabase.indexed-entities.init]
|
||||
[metabase.lib-be.init]
|
||||
@@ -80,6 +81,21 @@
|
||||
[metabase.warehouses.init]
|
||||
[metabase.xrays.init]))
|
||||
|
||||
;; Activate flargs BEFORE any EE init code runs and before anything user-facing happens. By the
|
||||
;; time control reaches this form, the module-level `:require`s above have already loaded their
|
||||
;; side-effectful init namespaces (classloader, logger, drivers, multimethods, etc.), so the
|
||||
;; shared classloader is ready and log output can be produced. Loading a flarg init ns causes its
|
||||
;; `defflarg` forms to register impls against `metabase.flargs.core/registry`; registrations
|
||||
;; purely update atoms, so later dispatcher calls will see them regardless of ordering between
|
||||
;; siblings. The ONE ordering constraint we care about — impls registered before any seam fn is
|
||||
;; invoked — is satisfied because `metabase.core.core/init!*` (which actually calls the
|
||||
;; application startup flow) runs strictly AFTER this namespace has finished loading.
|
||||
;;
|
||||
;; If RF_* env vars are misconfigured (flarg requested but not on the classpath), `activate!`
|
||||
;; throws — per FLARG-PROGRESS.md §Decisions (Approach A), that's the desired loud failure.
|
||||
(when-not *compile-files*
|
||||
(flargs.init/activate!))
|
||||
|
||||
;; load EE init code on system launch if it exists.
|
||||
(when (and (not *compile-files*)
|
||||
config/ee-available?)
|
||||
|
||||
118
src/metabase/flargs/core.clj
Normal file
118
src/metabase/flargs/core.clj
Normal file
@@ -0,0 +1,118 @@
|
||||
(ns metabase.flargs.core
|
||||
"Definition of the `defflarg` macro, which enables writing functions with a default (main-side) implementation and an
|
||||
optional override (flarg-side) implementation. Classpath-isolated in-progress features (\"flargs\") register their
|
||||
impls from a `metabase.flarg.<flarg-name>.*` or `metabase-enterprise.flarg.<flarg-name>.*` namespace, and those
|
||||
namespaces are only on the classpath when the corresponding flarg is enabled at build time.
|
||||
|
||||
The shape deliberately mirrors `metabase.premium-features.defenterprise` so anyone who already reads that fluently
|
||||
reads `defflarg` fluently. The key differences:
|
||||
|
||||
- No `:feature` option and no token check. The gate is classpath presence alone.
|
||||
- No `:fallback` option. If the flarg's namespace cannot be loaded, the default runs.
|
||||
- \"Side\" detection uses the `metabase(-enterprise)?.flarg.` namespace prefix."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.classloader.core :as classloader]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [tru]]))
|
||||
|
||||
(defonce
|
||||
^{:doc "A map from fully-qualified flarg function names to maps containing the default and, when one has been
|
||||
registered, impl implementations. Mirrors the shape of [[metabase.premium-features.defenterprise/registry]].
|
||||
|
||||
For example:
|
||||
{flarg-ns/flarg-fn-name {:default default-fn
|
||||
:impl impl-fn
|
||||
:flarg :flarg/some-flarg}}"}
|
||||
registry
|
||||
(atom {}))
|
||||
|
||||
(defn register-mapping!
|
||||
"Adds new values to the `registry` for `flarg-fn-name`, merging them onto any existing entry."
|
||||
[flarg-fn-name values]
|
||||
(swap! registry update flarg-fn-name merge values))
|
||||
|
||||
(defn dynamic-flarg-fn
|
||||
"Returns a dispatcher fn that, on first invocation, lazily tries to load `flarg-ns` (so any `defflarg` impls in that
|
||||
namespace register themselves against the `registry`). Subsequent calls skip the require. The dispatcher then looks
|
||||
up `flarg-fn-name` in the `registry` and invokes the impl if one is registered, otherwise the default.
|
||||
|
||||
If the require fails (ns not on classpath because the flarg is off), the exception is swallowed and the default runs
|
||||
from then on. This mirrors [[metabase.premium-features.defenterprise/dynamic-ee-oss-fn]]."
|
||||
[flarg-ns flarg-fn-name]
|
||||
(let [try-require-once (delay (u/ignore-exceptions (classloader/require flarg-ns)))]
|
||||
(fn [& args]
|
||||
@try-require-once
|
||||
(let [{:keys [default impl]} (get @registry flarg-fn-name)]
|
||||
(apply (or impl default) args)))))
|
||||
|
||||
(defn- in-flarg?
|
||||
"Is the current namespace a flarg namespace? Flarg namespaces are prefixed with `metabase.flarg.` or
|
||||
`metabase-enterprise.flarg.`."
|
||||
[]
|
||||
(let [nm (str (ns-name *ns*))]
|
||||
(or (str/starts-with? nm "metabase.flarg.")
|
||||
(str/starts-with? nm "metabase-enterprise.flarg."))))
|
||||
|
||||
(defn- docstr-exception
|
||||
"The exception to throw when `defflarg` is used without a docstring."
|
||||
[fn-name]
|
||||
(Exception. (tru "Flarg function {0}/{1} does not have a docstring. Go add one!" (ns-name *ns*) fn-name)))
|
||||
|
||||
(defn- validate-flarg-key
|
||||
"Throws if `flarg-key` is not a literal keyword whose namespace is \"flarg\" (e.g. `:flarg/workflows`)."
|
||||
[flarg-key]
|
||||
(when-not (and (keyword? flarg-key)
|
||||
(= "flarg" (namespace flarg-key)))
|
||||
(throw (ex-info (tru "defflarg requires a namespaced flarg keyword like :flarg/foo, got: {0}" (pr-str flarg-key))
|
||||
{:flarg-key flarg-key}))))
|
||||
|
||||
(defn- validate-flarg-ns
|
||||
"Throws if `flarg-ns` is not a symbol."
|
||||
[flarg-ns]
|
||||
(when-not (symbol? flarg-ns)
|
||||
(throw (ex-info (tru "defflarg requires a symbol naming the flarg-side namespace, got: {0}" (pr-str flarg-ns))
|
||||
{:flarg-ns flarg-ns}))))
|
||||
|
||||
(defn- validate-arglist
|
||||
"Throws if `arglist` is not a vector."
|
||||
[arglist]
|
||||
(when-not (vector? arglist)
|
||||
(throw (ex-info (tru "defflarg requires a vector arglist, got: {0}" (pr-str arglist))
|
||||
{:arglist arglist}))))
|
||||
|
||||
(defmacro defflarg
|
||||
"Defines a function that has a main-side default implementation and an optional flarg-side impl. Analogous to
|
||||
[[metabase.premium-features.defenterprise/defenterprise]], but the gate is classpath presence of the flarg's
|
||||
namespace rather than license token + EE edition.
|
||||
|
||||
Call shape:
|
||||
|
||||
(defflarg fn-name
|
||||
\"docstring\"
|
||||
:flarg/<flarg-name> ; literal keyword, namespace must be \"flarg\"
|
||||
metabase.flarg.<name>.<sub> ; symbol naming the flarg-side namespace
|
||||
[arg1 arg2]
|
||||
body...) ; default body on the main side, impl body on the flarg side
|
||||
|
||||
The same `defflarg` form is written on BOTH the main side (e.g. `src/metabase/...`) AND the flarg side
|
||||
(`release-flags/<flarg-name>/src/metabase/flarg/<flarg-name>/...`) with different bodies. At call time, if an impl is
|
||||
registered for this fn, the impl runs; otherwise the default runs. The flarg-side namespace is lazily required on
|
||||
first call; if it is not on the classpath, the require is silently ignored and the default runs."
|
||||
{:arglists '([fn-name docstring flarg-key flarg-ns arglist & body])}
|
||||
[fn-name docstring flarg-key flarg-ns arglist & body]
|
||||
{:pre [(symbol? fn-name)]}
|
||||
(when-not (string? docstring) (throw (docstr-exception fn-name)))
|
||||
(validate-flarg-key flarg-key)
|
||||
(validate-flarg-ns flarg-ns)
|
||||
(validate-arglist arglist)
|
||||
(let [main-or-flarg (if (in-flarg?) :impl :default)]
|
||||
`(let [flarg-ns# '~flarg-ns
|
||||
flarg-fn-name# (symbol (str flarg-ns# "/" '~fn-name))
|
||||
body-fn# (fn ~(symbol (str fn-name)) ~arglist ~@body)]
|
||||
(register-mapping! flarg-fn-name# {~main-or-flarg body-fn#
|
||||
:flarg ~flarg-key})
|
||||
(def
|
||||
~(vary-meta fn-name merge {:arglists ''([& args])})
|
||||
~docstring
|
||||
(dynamic-flarg-fn flarg-ns# flarg-fn-name#)))))
|
||||
93
src/metabase/flargs/init.clj
Normal file
93
src/metabase/flargs/init.clj
Normal file
@@ -0,0 +1,93 @@
|
||||
(ns metabase.flargs.init
|
||||
"Startup wiring that converts `RF_*` environment variables into loaded flarg-side `init`
|
||||
namespaces. Triggers the flarg-side `defflarg` forms to register their impls against the
|
||||
registry so the dispatcher in `metabase.flargs.core` can route to them.
|
||||
|
||||
Call shape: `(activate!)` at JVM startup reads the env, translates each truthy `RF_*` var to a
|
||||
`:flarg/<name>` keyword, and requires the corresponding `metabase.flarg.<name>.init` namespace.
|
||||
Any require failure throws immediately (Approach A per FLARG-PROGRESS.md §Decisions) — a
|
||||
misconfiguration where a flarg is requested via env var but its code isn't on the classpath
|
||||
must fail loud.
|
||||
|
||||
Env-var convention matches Ryan's already-shipped Liquibase context gating
|
||||
([[metabase.app-db.liquibase/enabled-rf-contexts]]): `RF_FOO_BAR=true` activates the flarg named
|
||||
`:flarg/foo-bar`. Truthy means literally `\"true\"` (case-insensitive, trimmed) — nothing else."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.classloader.core :as classloader]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private rf-prefix "RF_")
|
||||
|
||||
(defn- truthy?
|
||||
"Matches the convention used by [[metabase.app-db.liquibase/enabled-rf-contexts]]: a value is
|
||||
truthy iff it equals `\"true\"` after trimming and lowercasing. Anything else — including
|
||||
`\"false\"`, `\"0\"`, `\"\"`, or `nil` — is falsy."
|
||||
[v]
|
||||
(and (some? v)
|
||||
(= "true" (u/lower-case-en (str/trim v)))))
|
||||
|
||||
(defn- env-key->flarg-keyword
|
||||
"Translates `RF_FOO_BAR` → `:flarg/foo-bar`. Caller must have already verified the key starts
|
||||
with `RF_`."
|
||||
[env-key]
|
||||
(-> env-key
|
||||
(subs (count rf-prefix))
|
||||
u/lower-case-en
|
||||
(str/replace "_" "-")
|
||||
(->> (keyword "flarg"))))
|
||||
|
||||
(defn enabled-flargs
|
||||
"Returns the set of `:flarg/<name>` keywords for every truthy `RF_*` entry in `env`. An empty
|
||||
set means no flargs are enabled. Non-`RF_` entries are ignored.
|
||||
|
||||
With no arg, reads from the real process environment via `System/getenv`. Tests should pass an
|
||||
explicit env map to avoid mutating process env."
|
||||
([]
|
||||
(enabled-flargs (System/getenv)))
|
||||
([env]
|
||||
(into #{}
|
||||
(keep (fn [[k v]]
|
||||
(when (and (str/starts-with? k rf-prefix)
|
||||
(truthy? v))
|
||||
(env-key->flarg-keyword k))))
|
||||
env)))
|
||||
|
||||
(defn- flarg-keyword->init-ns
|
||||
"Translates `:flarg/foo-bar` → the symbol `metabase.flarg.foo-bar.init`."
|
||||
[flarg-kw]
|
||||
(symbol (str "metabase.flarg." (name flarg-kw) ".init")))
|
||||
|
||||
(defn require-flarg-inits!
|
||||
"For each flarg keyword in `flargs`, requires its `metabase.flarg.<name>.init` namespace via
|
||||
[[metabase.classloader.core/require]]. Iteration order is whatever the input set yields (no
|
||||
stability guarantee — order between flargs must not matter).
|
||||
|
||||
On the first require failure, throws `ex-info` with a remediation hint pointing at the
|
||||
`-A:flarg/<name>` alias. Remaining flargs are NOT attempted."
|
||||
[flargs]
|
||||
(doseq [flarg-kw flargs]
|
||||
(let [init-ns (flarg-keyword->init-ns flarg-kw)]
|
||||
(try
|
||||
(classloader/require init-ns)
|
||||
(catch Throwable e
|
||||
(throw (ex-info (str "Failed to load flarg init namespace " init-ns
|
||||
" for " flarg-kw ". "
|
||||
"Did you include `-A:" (namespace flarg-kw) "/" (name flarg-kw)
|
||||
"` in the JVM launch?")
|
||||
{:flarg flarg-kw
|
||||
:init-ns init-ns}
|
||||
e)))))))
|
||||
|
||||
(defn activate!
|
||||
"Reads the process env for `RF_*` flags and requires the corresponding flarg init namespaces so
|
||||
their `defflarg` impls register. Returns the set of activated flarg keywords (possibly empty)
|
||||
for logging and debuggability.
|
||||
|
||||
This is the single entry point called from `metabase.core.init`."
|
||||
[]
|
||||
(let [flargs (enabled-flargs)]
|
||||
(require-flarg-inits! flargs)
|
||||
flargs))
|
||||
209
test/metabase/flargs/core_test.clj
Normal file
209
test/metabase/flargs/core_test.clj
Normal file
@@ -0,0 +1,209 @@
|
||||
(ns metabase.flargs.core-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.classloader.core :as classloader]
|
||||
[metabase.flargs.core :as flargs]))
|
||||
|
||||
;;; -------------------------------------------------- 1.1 Registry --------------------------------------------------
|
||||
|
||||
(deftest register-mapping!-sets-value-test
|
||||
(testing "register-mapping! associates values with a function name"
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry]
|
||||
(flargs/register-mapping! 'some.ns/foo {:default :a-default})
|
||||
(is (= {'some.ns/foo {:default :a-default}} @registry))))))
|
||||
|
||||
(deftest register-mapping!-merges-test
|
||||
(testing "A second call to register-mapping! merges new keys into the existing map"
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry]
|
||||
(flargs/register-mapping! 'some.ns/foo {:default :a-default})
|
||||
(flargs/register-mapping! 'some.ns/foo {:impl :an-impl :flarg :flarg/name})
|
||||
(is (= {'some.ns/foo {:default :a-default
|
||||
:impl :an-impl
|
||||
:flarg :flarg/name}}
|
||||
@registry))))))
|
||||
|
||||
(deftest register-mapping!-isolates-keys-test
|
||||
(testing "Different function names are stored as separate entries"
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry]
|
||||
(flargs/register-mapping! 'some.ns/foo {:default :foo-default})
|
||||
(flargs/register-mapping! 'some.ns/bar {:default :bar-default})
|
||||
(is (= {'some.ns/foo {:default :foo-default}
|
||||
'some.ns/bar {:default :bar-default}}
|
||||
@registry))))))
|
||||
|
||||
;;; ------------------------------------------------- 1.2 Dispatcher -------------------------------------------------
|
||||
|
||||
(deftest dispatcher-only-default-test
|
||||
(testing "With only a default registered, the dispatcher calls the default"
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry]
|
||||
(flargs/register-mapping! 'some.ns/only-default
|
||||
{:default (fn [x] [:default x])})
|
||||
(let [f (flargs/dynamic-flarg-fn 'some.ns 'some.ns/only-default)]
|
||||
(is (= [:default 42] (f 42))))))))
|
||||
|
||||
(deftest dispatcher-with-impl-test
|
||||
(testing "With an impl registered, the dispatcher calls the impl"
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry]
|
||||
(flargs/register-mapping! 'some.ns/with-impl
|
||||
{:default (fn [x] [:default x])
|
||||
:impl (fn [x] [:impl x])})
|
||||
(let [f (flargs/dynamic-flarg-fn 'some.ns 'some.ns/with-impl)]
|
||||
(is (= [:impl 42] (f 42))))))))
|
||||
|
||||
(deftest dispatcher-preserves-varargs-test
|
||||
(testing "The dispatcher forwards all args (including varargs) to the selected fn"
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry]
|
||||
(flargs/register-mapping! 'some.ns/varargs-default
|
||||
{:default (fn [& args] [:default args])})
|
||||
(flargs/register-mapping! 'some.ns/varargs-impl
|
||||
{:default (fn [& _] :ignored)
|
||||
:impl (fn [& args] [:impl args])})
|
||||
(let [f-default (flargs/dynamic-flarg-fn 'some.ns 'some.ns/varargs-default)
|
||||
f-impl (flargs/dynamic-flarg-fn 'some.ns 'some.ns/varargs-impl)]
|
||||
(is (= [:default '(1 2 3)] (f-default 1 2 3)))
|
||||
(is (= [:impl '(:a :b)] (f-impl :a :b))))))))
|
||||
|
||||
(deftest dispatcher-preserves-return-value-test
|
||||
(testing "The dispatcher returns whatever the underlying fn returns (including nil)"
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry]
|
||||
(flargs/register-mapping! 'some.ns/returns-nil
|
||||
{:default (fn [_] nil)})
|
||||
(flargs/register-mapping! 'some.ns/returns-map
|
||||
{:default (fn [_] :ignored)
|
||||
:impl (fn [_] {:ok true})})
|
||||
(let [f-nil (flargs/dynamic-flarg-fn 'some.ns 'some.ns/returns-nil)
|
||||
f-map (flargs/dynamic-flarg-fn 'some.ns 'some.ns/returns-map)]
|
||||
(is (nil? (f-nil :anything)))
|
||||
(is (= {:ok true} (f-map :anything))))))))
|
||||
|
||||
;;; ------------------------------------------------ 1.3 Lazy require ------------------------------------------------
|
||||
|
||||
(deftest lazy-require-called-exactly-once-test
|
||||
(testing "classloader/require is called exactly once across many invocations"
|
||||
(let [registry (atom {})
|
||||
call-count (atom 0)]
|
||||
(with-redefs [flargs/registry registry
|
||||
classloader/require (fn [& _] (swap! call-count inc))]
|
||||
(flargs/register-mapping! 'some.ns/lazy {:default (fn [x] x)})
|
||||
(let [f (flargs/dynamic-flarg-fn 'some.ns 'some.ns/lazy)]
|
||||
(dotimes [_ 5] (f :x))
|
||||
(is (= 1 @call-count)))))))
|
||||
|
||||
(deftest lazy-require-failure-uses-default-test
|
||||
(testing "If classloader/require throws, the default is still used (exception is swallowed)"
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry
|
||||
classloader/require (fn [& _] (throw (ex-info "boom" {})))]
|
||||
(flargs/register-mapping! 'some.ns/boom {:default (fn [x] [:default x])})
|
||||
(let [f (flargs/dynamic-flarg-fn 'some.ns 'some.ns/boom)]
|
||||
(is (= [:default :ok] (f :ok))))))))
|
||||
|
||||
;;; -------------------------------------------------- 1.4 Macro -----------------------------------------------------
|
||||
|
||||
(deftest ^:parallel macro-requires-docstring-test
|
||||
(testing "defflarg without a docstring throws a compile-time exception"
|
||||
(is (thrown? Exception
|
||||
(macroexpand
|
||||
'(metabase.flargs.core/defflarg foo
|
||||
:flarg/foo
|
||||
metabase.flarg.foo.core
|
||||
[x]
|
||||
x))))))
|
||||
|
||||
(deftest ^:parallel macro-requires-literal-keyword-flarg-test
|
||||
(testing "defflarg requires a literal keyword whose namespace is 'flarg'"
|
||||
(is (thrown-with-msg? Exception #"(?i)flarg"
|
||||
(macroexpand
|
||||
'(metabase.flargs.core/defflarg foo
|
||||
"docstring"
|
||||
:foo ; not namespaced
|
||||
metabase.flarg.foo.core
|
||||
[x]
|
||||
x))))
|
||||
(is (thrown-with-msg? Exception #"(?i)flarg"
|
||||
(macroexpand
|
||||
'(metabase.flargs.core/defflarg foo
|
||||
"docstring"
|
||||
:other/foo ; wrong namespace
|
||||
metabase.flarg.foo.core
|
||||
[x]
|
||||
x))))
|
||||
(is (thrown? Exception
|
||||
(macroexpand
|
||||
'(metabase.flargs.core/defflarg foo
|
||||
"docstring"
|
||||
"not-a-keyword"
|
||||
metabase.flarg.foo.core
|
||||
[x]
|
||||
x))))))
|
||||
|
||||
(deftest ^:parallel macro-requires-symbol-flarg-ns-test
|
||||
(testing "defflarg requires the flarg-ns argument to be a symbol"
|
||||
(is (thrown? Exception
|
||||
(macroexpand
|
||||
'(metabase.flargs.core/defflarg foo
|
||||
"docstring"
|
||||
:flarg/foo
|
||||
"not-a-symbol"
|
||||
[x]
|
||||
x))))))
|
||||
|
||||
(deftest ^:parallel macro-requires-vector-arglist-test
|
||||
(testing "defflarg requires the arglist to be a vector"
|
||||
(is (thrown? Exception
|
||||
(macroexpand
|
||||
'(metabase.flargs.core/defflarg foo
|
||||
"docstring"
|
||||
:flarg/foo
|
||||
metabase.flarg.foo.core
|
||||
(x) ; a list, not a vector
|
||||
x))))))
|
||||
|
||||
;;; ---------------------------------------------- 1.5 Integration ---------------------------------------------------
|
||||
|
||||
(deftest in-process-integration-test
|
||||
(testing "A defflarg whose flarg namespace exists uses the impl; one whose flarg namespace does not exist uses the default"
|
||||
;; Reset the registry for a clean slate. Use a fresh atom so we don't stomp any shared state.
|
||||
(let [registry (atom {})]
|
||||
(with-redefs [flargs/registry registry]
|
||||
;; "Main side" ns: not prefixed with metabase.flarg. or metabase-enterprise.flarg.
|
||||
(let [main-ns (create-ns 'metabase.flargs.core-test.fake-main)]
|
||||
(binding [*ns* main-ns]
|
||||
(refer-clojure)
|
||||
(require '[metabase.flargs.core :as flargs])
|
||||
(eval '(flargs/defflarg impl-will-load
|
||||
"Will be overridden by the flarg-side impl."
|
||||
:flarg/integration-test
|
||||
metabase.flarg.integration-test.impl
|
||||
[x]
|
||||
[:default x]))
|
||||
(eval '(flargs/defflarg impl-missing
|
||||
"Flarg ns does not exist, so default wins."
|
||||
:flarg/missing
|
||||
metabase.flarg.integration-test.nope.does.not.exist
|
||||
[x]
|
||||
[:default-only x]))))
|
||||
;; "Flarg side" ns: prefixed with metabase.flarg.
|
||||
(let [flarg-ns (create-ns 'metabase.flarg.integration-test.impl)]
|
||||
(binding [*ns* flarg-ns]
|
||||
(refer-clojure)
|
||||
(require '[metabase.flargs.core :as flargs])
|
||||
(eval '(flargs/defflarg impl-will-load
|
||||
"Impl used when flarg is on."
|
||||
:flarg/integration-test
|
||||
metabase.flarg.integration-test.impl
|
||||
[x]
|
||||
[:impl x]))))
|
||||
(is (= [:impl :hello]
|
||||
((resolve 'metabase.flargs.core-test.fake-main/impl-will-load) :hello))
|
||||
"When a matching flarg-side impl is registered, it runs instead of the default")
|
||||
(is (= [:default-only :hello]
|
||||
((resolve 'metabase.flargs.core-test.fake-main/impl-missing) :hello))
|
||||
"When the flarg ns cannot be loaded, the default runs")))))
|
||||
158
test/metabase/flargs/init_test.clj
Normal file
158
test/metabase/flargs/init_test.clj
Normal file
@@ -0,0 +1,158 @@
|
||||
(ns metabase.flargs.init-test
|
||||
"Unit tests for the env-var → flarg activation wiring. These tests mock the environment and the
|
||||
classloader require side so they don't touch the real process env or the real classpath."
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.classloader.core :as classloader]
|
||||
[metabase.flargs.init :as flargs.init]))
|
||||
|
||||
;;; ----------------------------------------- enabled-flargs (pure) -----------------------------------------
|
||||
|
||||
(deftest ^:parallel enabled-flargs-empty-env-test
|
||||
(testing "With no env vars at all, returns an empty set"
|
||||
(is (= #{} (flargs.init/enabled-flargs {})))))
|
||||
|
||||
(deftest ^:parallel enabled-flargs-no-rf-vars-test
|
||||
(testing "Non-RF_ env vars are ignored"
|
||||
(is (= #{} (flargs.init/enabled-flargs {"PATH" "/usr/bin"
|
||||
"HOME" "/home/me"
|
||||
"METABASE_FOO" "true"
|
||||
"RFOO" "true" ; missing underscore
|
||||
"rf_lower" "true"}))))) ; lowercase rf_
|
||||
|
||||
(deftest ^:parallel enabled-flargs-single-truthy-test
|
||||
(testing "A single truthy RF_ var produces a single :flarg/* keyword"
|
||||
(is (= #{:flarg/foo} (flargs.init/enabled-flargs {"RF_FOO" "true"})))))
|
||||
|
||||
(deftest ^:parallel enabled-flargs-multi-segment-name-test
|
||||
(testing "Underscores in the flarg name become dashes in the keyword"
|
||||
(is (= #{:flarg/foo-bar-baz}
|
||||
(flargs.init/enabled-flargs {"RF_FOO_BAR_BAZ" "true"})))))
|
||||
|
||||
(deftest ^:parallel enabled-flargs-truthy-values-test
|
||||
(testing "Case-insensitive, whitespace-trimmed 'true' is truthy; everything else is falsy"
|
||||
(are [v enabled?] (= (if enabled? #{:flarg/x} #{})
|
||||
(flargs.init/enabled-flargs {"RF_X" v}))
|
||||
"true" true
|
||||
"TRUE" true
|
||||
"True" true
|
||||
" true " true
|
||||
"false" false
|
||||
"False" false
|
||||
"0" false
|
||||
"1" false ; only the literal string "true" counts
|
||||
"yes" false
|
||||
"" false
|
||||
" " false)))
|
||||
|
||||
(deftest ^:parallel enabled-flargs-nil-value-test
|
||||
(testing "A nil value is not truthy"
|
||||
(is (= #{} (flargs.init/enabled-flargs {"RF_X" nil})))))
|
||||
|
||||
(deftest ^:parallel enabled-flargs-mixed-test
|
||||
(testing "A mix of truthy and falsy RF_ vars plus noise yields only the truthy ones"
|
||||
(is (= #{:flarg/a :flarg/c}
|
||||
(flargs.init/enabled-flargs {"RF_A" "true"
|
||||
"RF_B" "false"
|
||||
"RF_C" "TRUE"
|
||||
"PATH" "/usr/bin"
|
||||
"METABASE_FOO" "true"})))))
|
||||
|
||||
;;; ------------------------------------- require-flarg-inits! (mockable) -------------------------------------
|
||||
|
||||
;; These tests use `with-redefs` to mock `classloader/require`, which the Metabase hawk test
|
||||
;; runner forbids under `^:parallel`. They're intentionally sequential.
|
||||
(deftest require-flarg-inits-empty-test
|
||||
(testing "With an empty set, classloader/require is not called"
|
||||
(let [calls (atom [])]
|
||||
(with-redefs [classloader/require (fn [& args] (swap! calls conj args))]
|
||||
(flargs.init/require-flarg-inits! #{})
|
||||
(is (= [] @calls))))))
|
||||
|
||||
(deftest require-flarg-inits-single-test
|
||||
(testing "With one flarg, classloader/require is called once with the right init symbol"
|
||||
(let [calls (atom [])]
|
||||
(with-redefs [classloader/require (fn [& args] (swap! calls conj args))]
|
||||
(flargs.init/require-flarg-inits! #{:flarg/my-flarg})
|
||||
(is (= [['metabase.flarg.my-flarg.init]] @calls))))))
|
||||
|
||||
(deftest require-flarg-inits-multi-test
|
||||
(testing "With multiple flargs, classloader/require is called once per flarg"
|
||||
(let [calls (atom [])]
|
||||
(with-redefs [classloader/require (fn [& args] (swap! calls conj args))]
|
||||
(flargs.init/require-flarg-inits! #{:flarg/a :flarg/b :flarg/c})
|
||||
(is (= #{['metabase.flarg.a.init]
|
||||
['metabase.flarg.b.init]
|
||||
['metabase.flarg.c.init]}
|
||||
(set @calls)))
|
||||
(is (= 3 (count @calls)))))))
|
||||
|
||||
(deftest require-flarg-inits-failure-throws-test
|
||||
(testing "A require failure throws ex-info naming the flarg and init-ns with an alias hint"
|
||||
(with-redefs [classloader/require (fn [& _] (throw (ex-info "boom" {})))]
|
||||
(let [e (try (flargs.init/require-flarg-inits! #{:flarg/broken})
|
||||
nil
|
||||
(catch Exception e e))]
|
||||
(is (some? e) "An exception is thrown")
|
||||
(is (= :flarg/broken (:flarg (ex-data e))))
|
||||
(is (= 'metabase.flarg.broken.init (:init-ns (ex-data e))))
|
||||
(is (re-find #"metabase\.flarg\.broken\.init" (ex-message e)))
|
||||
(is (re-find #":flarg/broken" (ex-message e)))
|
||||
(is (re-find #"-A:flarg/broken" (ex-message e))
|
||||
"The error message includes the remediation alias hint")))))
|
||||
|
||||
(deftest require-flarg-inits-first-failure-wins-test
|
||||
(testing "When a require fails, subsequent flargs are NOT attempted"
|
||||
(let [calls (atom [])
|
||||
fail-on-2nd (let [n (atom 0)]
|
||||
(fn [& args]
|
||||
(swap! calls conj args)
|
||||
(when (= 2 (swap! n inc))
|
||||
(throw (ex-info "second call boom" {})))))]
|
||||
(with-redefs [classloader/require fail-on-2nd]
|
||||
(is (thrown? Exception
|
||||
;; vector forces iteration order so we can assert which attempts happened
|
||||
(flargs.init/require-flarg-inits! [:flarg/first :flarg/second :flarg/third])))
|
||||
(is (= 2 (count @calls))
|
||||
"Third flarg was not attempted after the second one failed")
|
||||
(is (= [['metabase.flarg.first.init]
|
||||
['metabase.flarg.second.init]]
|
||||
@calls))))))
|
||||
|
||||
;;; ------------------------------------------ activate! (smoke) ------------------------------------------
|
||||
|
||||
(deftest activate!-orchestrates-and-returns-set-test
|
||||
(testing "activate! consults enabled-flargs and requires each init, returning the activated set"
|
||||
(let [calls (atom [])]
|
||||
(with-redefs [flargs.init/enabled-flargs (fn
|
||||
([] #{:flarg/one :flarg/two})
|
||||
([_] #{:flarg/one :flarg/two}))
|
||||
classloader/require (fn [& args] (swap! calls conj args))]
|
||||
(is (= #{:flarg/one :flarg/two} (flargs.init/activate!)))
|
||||
(is (= #{['metabase.flarg.one.init]
|
||||
['metabase.flarg.two.init]}
|
||||
(set @calls)))))))
|
||||
|
||||
(deftest activate!-empty-no-requires-test
|
||||
(testing "When no flargs are enabled, activate! returns an empty set and requires nothing"
|
||||
(let [calls (atom [])]
|
||||
(with-redefs [flargs.init/enabled-flargs (fn
|
||||
([] #{})
|
||||
([_] #{}))
|
||||
classloader/require (fn [& args] (swap! calls conj args))]
|
||||
(is (= #{} (flargs.init/activate!)))
|
||||
(is (= [] @calls))))))
|
||||
|
||||
;;; --------------------------- End-to-end (documented, runs via a separate command) ---------------------------
|
||||
|
||||
;; A real end-to-end check requires a fresh JVM with both the :flarg/test-flarg deps.edn alias AND
|
||||
;; the RF_TEST_FLARG env var set so that metabase.flargs.init/activate! requires the
|
||||
;; metabase.flarg.test-flarg.init namespace, which registers its impl against the flarg registry.
|
||||
;;
|
||||
;; Coordinator-runnable command (see metabase.flargs.integration-test for the cached-load rationale):
|
||||
;;
|
||||
;; RF_TEST_FLARG=true clojure -X:dev:ee:ee-dev:test:flarg/test-flarg \
|
||||
;; :only '[metabase.flargs.integration-test]'
|
||||
;;
|
||||
;; This test namespace deliberately does not attempt it because it would mutate process env and
|
||||
;; depend on classpath state outside the unit-test harness.
|
||||
14
test/metabase/flargs/integration_seam.clj
Normal file
14
test/metabase/flargs/integration_seam.clj
Normal file
@@ -0,0 +1,14 @@
|
||||
(ns metabase.flargs.integration-seam
|
||||
"Test-only main-side seam. Defines a [[defflarg]] whose default returns `:default`. The flarg-side
|
||||
impl in `metabase.flarg.test-flarg.core` (on the classpath only when `:flarg/test-flarg` is
|
||||
active) overrides this to return `:impl`. Used by [[metabase.flargs.integration-test]]."
|
||||
(:require
|
||||
[metabase.flargs.core :as flargs]))
|
||||
|
||||
(flargs/defflarg test-fn
|
||||
"Default: returns `:default`. The flarg-side impl in `metabase.flarg.test-flarg.core` overrides
|
||||
this to return `:impl` when the flarg is enabled."
|
||||
:flarg/test-flarg
|
||||
metabase.flarg.test-flarg.core
|
||||
[]
|
||||
:default)
|
||||
41
test/metabase/flargs/integration_test.clj
Normal file
41
test/metabase/flargs/integration_test.clj
Normal file
@@ -0,0 +1,41 @@
|
||||
(ns metabase.flargs.integration-test
|
||||
"Integration test for the `defflarg` machinery exercised under real classpath isolation.
|
||||
|
||||
This test is alias-aware: it inspects the classpath to determine whether the flarg's namespace is
|
||||
loadable, then asserts the expected behavior for that condition. To exercise BOTH conditions, run
|
||||
it twice:
|
||||
|
||||
- Without `:flarg/test-flarg` (default REPL/test config): asserts the `:default` branch runs.
|
||||
- With `:flarg/test-flarg`: asserts the `:impl` branch runs.
|
||||
|
||||
Coordinator-verifiable command for the on-alias path:
|
||||
|
||||
clojure -X:dev:ee:ee-dev:test:flarg/test-flarg \\
|
||||
:only '[metabase.flargs.integration-test]'
|
||||
|
||||
Because the lazy require in `metabase.flargs.core/dynamic-flarg-fn` caches its first result, any
|
||||
impl registration must happen before the seam function is first invoked. The `flarg-loadable?`
|
||||
probe below requires the flarg-side namespace (triggering the `defflarg` impl registration if
|
||||
it's on the classpath) before any call to the seam. Calling the seam without that require first
|
||||
would race the lazy-load and observe whatever state happens to be cached."
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.flargs.integration-seam :as seam]))
|
||||
|
||||
(defn- flarg-loadable?
|
||||
"Returns true when the flarg-side namespace can be loaded on the current classpath. Loading also
|
||||
triggers the flarg-side `defflarg` form to register its impl against the registry."
|
||||
[]
|
||||
(try
|
||||
(require 'metabase.flarg.test-flarg.core)
|
||||
true
|
||||
(catch Exception _ false)))
|
||||
|
||||
(deftest defflarg-classpath-isolation-test
|
||||
(let [loadable? (flarg-loadable?)]
|
||||
(testing (str "When the flarg ns is "
|
||||
(if loadable? "loadable" "NOT loadable")
|
||||
", the dispatcher routes to the "
|
||||
(if loadable? "impl" "default"))
|
||||
(is (= (if loadable? :impl :default)
|
||||
(seam/test-fn))))))
|
||||
20
test/metabase/flargs/release_flags_gate_test.clj
Normal file
20
test/metabase/flargs/release_flags_gate_test.clj
Normal file
@@ -0,0 +1,20 @@
|
||||
(ns metabase.flargs.release-flags-gate-test
|
||||
"Merge gate: the `.release-flags` file MUST be absent for a commit to be mergeable to master.
|
||||
|
||||
The file is the configuration source of truth for which flargs are enabled for a build. During
|
||||
feature development on a PR branch, the file is committed so CI and PR Envs run with the flarg
|
||||
enabled. Before merge, the developer deletes the file and CI re-runs with all flargs off to
|
||||
prove the main tree is still clean.
|
||||
|
||||
See FLARG.md § How `.release-flags` interacts with CI and FLARG-PROGRESS.md § Phase 4."
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.test :refer :all]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(deftest ^:parallel no-release-flags-file-on-master-test
|
||||
(testing "The .release-flags file must be absent for a commit to be mergeable to master"
|
||||
(is (not (.exists (io/file ".release-flags")))
|
||||
(str "You committed .release-flags. Remove it before merging to master. "
|
||||
"See FLARG.md § How `.release-flags` interacts with CI."))))
|
||||
@@ -371,8 +371,7 @@
|
||||
(defn- test-var-with-context
|
||||
"A modified version of `clojure.test/test-var` that:
|
||||
- logs every toucan2 query we run, with details on the query type, model, args, and resulting query
|
||||
- adds some context to any logs emitted during the test, so that we have information on what test ran
|
||||
"
|
||||
- adds some context to any logs emitted during the test, so that we have information on what test ran"
|
||||
[v]
|
||||
(let [test-n (-> v meta :name)
|
||||
test-ns (-> v meta :ns str)]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[mb.hawk.core :as hawk]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.core.bootstrap]
|
||||
[metabase.test]
|
||||
[metabase.test-runner.assert-exprs]
|
||||
[metabase.test.data.env :as tx.env]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
|
||||
Reference in New Issue
Block a user