diff --git a/.gitignore b/.gitignore index 8f612c64cafb24e5cb6738c191ba61a5bbfe21b6..8f0d96f4f59db99ce7d03b6c75801425e339cc00 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ /stats.json /target /test-report-* +OSX/.cpcache OSX/Metabase/jre OSX/Resources/metabase.jar OSX/build diff --git a/OSX/Metabase/Metabase-Info.plist b/OSX/Metabase/Metabase-Info.plist index fdbbbe212d4b26e45b28e168ba0813fe4f41661d..2fba09c93e294160818a74fe75069c302738583e 100644 --- a/OSX/Metabase/Metabase-Info.plist +++ b/OSX/Metabase/Metabase-Info.plist @@ -17,11 +17,11 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>0.15.0.0</string> + <string>0.34.3</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>0.15.0.0</string> + <string>0.34.3</string> <key>LSApplicationCategoryType</key> <string>public.app-category.utilities</string> <key>LSMinimumSystemVersion</key> diff --git a/OSX/deps.edn b/OSX/deps.edn new file mode 100644 index 0000000000000000000000000000000000000000..20c635d42cd50152e705525a68fa640c76921c86 --- /dev/null +++ b/OSX/deps.edn @@ -0,0 +1,12 @@ +{:paths ["./"] + + :deps + {org.clojure/data.xml {:mvn/version "0.0.8"} + cheshire {:mvn/version "5.8.1"} + clj-http {:mvn/version "3.9.1"} + clj-tagsoup {:mvn/version "0.3.0"} + commons-io/commons-io {:mvn/version "2.6"} + colorize {:mvn/version "0.1.1"} + environ {:mvn/version "1.1.0"} + hiccup {:mvn/version "1.0.5"} + org.flatland/ordered {:mvn/version "1.5.7"}}} diff --git a/OSX/macos_release.clj b/OSX/macos_release.clj new file mode 100644 index 0000000000000000000000000000000000000000..5f3b7712ed5dd7c729d0304c556f2a5f5657f2c6 --- /dev/null +++ b/OSX/macos_release.clj @@ -0,0 +1,45 @@ +(ns macos-release + (:require [colorize.core :as colorize] + [flatland.ordered.map :as ordered-map] + [macos-release + [build :as build] + [codesign :as codesign] + [common :as c] + [create-dmg :as create-dmg] + [notarize :as notarize] + [sparkle-artifacts :as sparkle-artifacts] + [upload :as upload]])) + +(set! *warn-on-reflection* true) + +(def ^:private steps* + (ordered-map/ordered-map + :build build/build! + :codesign codesign/codesign! + :generate-sparkle-artifacts sparkle-artifacts/generate-sparkle-artifacts! + :create-dmg create-dmg/create-dmg! + :notarize notarize/notarize! + :upload upload/upload!)) + +(defn- do-step! [step-name] + (let [thunk (or (get steps* (keyword step-name)) + (throw (ex-info (format "Invalid step name: %s" step-name) + {:found (set (keys steps*))})))] + (println (colorize/magenta (format "Running step %s..." step-name))) + (thunk))) + +(defn- do-steps! [steps] + (c/announce "Running steps: %s" steps) + (doseq [step-name steps] + (do-step! step-name)) + (c/announce "Success.")) + +(defn -main [& steps] + (let [steps (or (seq steps) + (keys steps*))] + (try + (do-steps! steps) + (catch Throwable e + (println (colorize/red (pr-str e))) + (System/exit -1)))) + (System/exit 0)) diff --git a/OSX/macos_release/addShortcut.scpt b/OSX/macos_release/addShortcut.scpt new file mode 100644 index 0000000000000000000000000000000000000000..5c967cffd270b07f2cb900233811f4e59b6213d4 --- /dev/null +++ b/OSX/macos_release/addShortcut.scpt @@ -0,0 +1,18 @@ +tell application "Finder" + tell disk "Metabase" + open + set current view of container window to icon view + set toolbar visible of container window to false + set statusbar visible of container window to false + set the bounds of container window to {400, 100, 885, 430} + set theViewOptions to the icon view options of container window + set arrangement of theViewOptions to not arranged + set icon size of theViewOptions to 72 + make new alias file at container window to POSIX file "/Applications" with properties {name:"Applications"} + set position of item "Metabase.app" of container window to {100, 100} + set position of item "Applications" of container window to {375, 100} + update without registering applications + delay 5 + close + end tell +end tell diff --git a/OSX/macos_release/build.clj b/OSX/macos_release/build.clj new file mode 100644 index 0000000000000000000000000000000000000000..5a7a2b4c64388b0642d2c0329601fbc75970df78 --- /dev/null +++ b/OSX/macos_release/build.clj @@ -0,0 +1,78 @@ +(ns macos-release.build + (:require [clojure.string :as str] + [macos-release.common :as c])) + +(def ^String info-plist-file + (c/assert-file-exists (str c/macos-source-dir "/Metabase/Metabase-Info.plist"))) + +(def ^String export-options-plist-file + (c/assert-file-exists (str c/macos-source-dir "/exportOptions.plist"))) + +(def ^String xcode-project-file + (c/assert-file-exists (str c/macos-source-dir "/Metabase.xcodeproj"))) + +(defn- plist-buddy + "Run a `PlistBuddy` command." + [plist-file command] + (let [[out] (c/sh (c/assert-file-exists "/usr/libexec/PlistBuddy") + "-c" (str command) + (c/assert-file-exists plist-file))] + (some-> out str/trim))) + +(defn- plist-value + "Fetch value `k` from a Plist file. + + (plist-value config/info-plist-file \"CFBundleVersion\") ; -> \"0.34.2.0\"" + [plist-filename k] + (plist-buddy plist-filename (format "Print %s" (str k)))) + +(defn- set-plist-value! + "Set value of `k` in a Plist file. Verifies version is set correctly." + [plist-filename k v] + (plist-buddy plist-filename (format "Set %s %s" (str k) (str v))) + (assert (= (plist-value plist-filename k) v)) + v) + +(defn- xcode-build [& args] + (apply c/sh "xcodebuild" "-UseNewBuildSystem=NO" args)) + +(defn- set-version! [] + (c/step (format "Bump version from %s -> %s" (plist-value info-plist-file "CFBundleVersion") (c/version)) + (set-plist-value! info-plist-file "CFBundleVersion" (c/version)) + (set-plist-value! info-plist-file "CFBundleShortVersionString" (c/version)))) + +(defn- clean! [] + (c/step "Clean XCode build artifacts" + (xcode-build "-project" xcode-project-file "clean") + (c/delete-file! c/artifacts-directory))) + +(defn- build-xcarchive! [] + (let [filename (c/artifact "Metabase.xcarchive")] + (c/delete-file! filename) + (c/step (format "Build %s" filename) + (xcode-build "-project" xcode-project-file + "-scheme" "Metabase" + "-configuration" "Release" + "-archivePath" filename + "archive") + (c/assert-file-exists filename)))) + +(defn- build-app! [] + (let [filename (c/artifact "Metabase.app")] + (c/delete-file! filename) + (c/step (format "Create %s" filename) + (xcode-build "-exportArchive" + "-exportOptionsPlist" export-options-plist-file + "-archivePath" (c/assert-file-exists (c/artifact "Metabase.xcarchive")) + "-exportPath" c/artifacts-directory) + (c/assert-file-exists filename)))) + +(defn build! [] + (c/step "Build artifacts" + (c/assert-file-exists (str c/macos-source-dir "/Metabase/jre/bin/java") + "Make sure you copy the JRE it before building Mac App (see build instructions)") + (set-version!) + (clean!) + (build-xcarchive!) + (build-app!) + (c/announce "Metabase.app built sucessfully."))) diff --git a/OSX/macos_release/codesign.clj b/OSX/macos_release/codesign.clj new file mode 100644 index 0000000000000000000000000000000000000000..c3eb086dc50aa7ec8344d11206e589e3ea0d9404 --- /dev/null +++ b/OSX/macos_release/codesign.clj @@ -0,0 +1,50 @@ +(ns macos-release.codesign + (:require [clojure.string :as str] + [macos-release.common :as c])) + +(def ^:private codesigning-identity "Developer ID Application: Metabase, Inc") + +(defn- entitlements-file [] + (c/assert-file-exists (str c/macos-source-dir "/Metabase/Metabase.entitlements"))) + +(defn- codesign-file! [filename] + (c/step (format "Code sign %s" filename) + (c/sh "codesign" "--force" "--verify" + #_"-vvv" + "--sign" codesigning-identity + "-r=designated => anchor trusted" + "--timestamp" + "--options" "runtime" + "--entitlements" (entitlements-file) + "--deep" + (c/assert-file-exists filename)) + (c/announce "Codesigned %s." filename))) + +(defn verify-codesign [filename] + (c/step (format "Verify code signature of %s" filename) + (c/sh "codesign" "--verify" "--deep" + "--display" + "--strict" + #_"--verbose=4" + (c/assert-file-exists filename)) + ;; double + (c/step "Check codesigning status with the System Policy Security Tool" + (c/sh "spctl" "--assess" + #_"--verbose=4" + "--type" "exec" + filename)) + (when (str/ends-with? filename "Metabase.app") + (doseq [file ["/Contents/MacOS/Metabase" + "/Contents/Frameworks/Sparkle.framework/Versions/Current/Resources/Autoupdate.app" + "/Contents/Frameworks/Sparkle.framework/Versions/Current/Resources/Autoupdate.app/Contents/MacOS/Autoupdate" + "/Contents/Frameworks/Sparkle.framework/Versions/A/Resources/Autoupdate.app/Contents/MacOS/Autoupdate"]] + (verify-codesign (str filename file)))) + (c/announce "Codesign for %s is valid." filename))) + +(defn codesign! [] + (c/step "Codesign" + (let [app (c/assert-file-exists (c/artifact "Metabase.app")) + sparkle-app (c/assert-file-exists (str app "/Contents/Frameworks/Sparkle.framework/Versions/A/Resources/AutoUpdate.app"))] + (doseq [file [sparkle-app app]] + (codesign-file! file) + (verify-codesign file))))) diff --git a/OSX/macos_release/common.clj b/OSX/macos_release/common.clj new file mode 100644 index 0000000000000000000000000000000000000000..82d21e99676f40a9ea63200fe97059b5449b5d19 --- /dev/null +++ b/OSX/macos_release/common.clj @@ -0,0 +1,169 @@ +(ns macos-release.common + (:require [clojure.string :as str] + [colorize.core :as colorize] + [environ.core :as env]) + (:import [java.io BufferedReader File InputStreamReader] + org.apache.commons.io.FileUtils)) + +(def ^String macos-source-dir + "e.g. /Users/cam/metabase/OSX" + (env/env :user-dir)) + +(assert (str/ends-with? macos-source-dir "/OSX") + "Please switch to the /OSX directory before running macos_release.clj") + +(def ^String root-directory + "e.g. /Users/cam/metabase" + (.getParent (File. macos-source-dir))) + +(def ^String artifacts-directory + "e.g. /Users/cam/metabase/osx-artifacts" + (str root-directory "/osx-artifacts")) + +;;; ---------------------------------------------------- Util Fns ---------------------------------------------------- + +(def ^:dynamic *steps* []) + +(def ^:private step-indent (str/join (repeat 2 \space))) + +(defn- steps-indent [] + (str/join (repeat (count *steps*) step-indent))) + +(defn safe-println [& args] + (locking println + (print (steps-indent)) + (apply println args))) + +(defn announce + "Like `println` + `format`, but outputs text in green. Use this for printing messages such as when starting build + steps." + ([s] + (safe-println (colorize/magenta s))) + + ([format-string & args] + (announce (apply format (str format-string) args)))) + +(defn do-step [step thunk] + (safe-println (colorize/green (str step))) + (binding [*steps* (conj *steps* step)] + (try + (thunk) + (catch Throwable e + (throw (ex-info (str step) {} e)))))) + +(defmacro step {:style/indent 1} [step & body] + `(do-step ~step (fn [] ~@body))) + +(defn exists? [^String filename] + (when filename + (.exists (File. filename)))) + +(defn assert-file-exists + "If file with `filename` exists, return `filename` as is; otherwise, throw Exception." + ^String [filename & [message]] + (when-not (exists? filename) + (throw (ex-info (format "File %s does not exist. %s" (pr-str filename) (or message "")) {:filename filename}))) + (str filename)) + +(defn create-directory-unless-exists! [^String dir] + (when-not (exists? dir) + (step (format "Creating directory %s..." dir) + (.mkdirs (File. dir)))) + dir) + +(defn artifact + "Return the full path of a file in the build artifacts directory." + ^String [filename] + (create-directory-unless-exists! artifacts-directory) + (str artifacts-directory "/" filename)) + +(defn delete-file! + "Delete a file or directory if it exists." + ([^String filename] + (step (format "Deleting %s..." filename) + (if (exists? filename) + (let [file (File. filename)] + (if (.isDirectory file) + (FileUtils/deleteDirectory file) + (.delete file)) + (safe-println (format "Deleted %s." filename))) + (safe-println (format "Don't need to delete %s, file does not exist." filename))) + (assert (not (exists? filename))))) + + ([file & more] + (dorun (map delete-file! (cons file more))))) + +(declare sh) + +(defn copy-file! [^String source ^String dest] + (let [source-file (File. (assert-file-exists source)) + dest-file (File. dest)] + ;; Use native `cp` rather than FileUtils or the like because codesigning is broken when you use those because they + ;; don't preserve symlinks or something like that. + (if (.isDirectory source-file) + (step (format "Copying directory %s -> %s" source dest) + (sh "cp" "-R" source dest)) + (step (format "Copying file %s -> %s" source dest) + (sh "cp" source dest)))) + (assert-file-exists dest)) + +(defn- read-lines [^java.io.BufferedReader reader {:keys [quiet? err?]}] + (loop [lines []] + (if-let [line (.readLine reader)] + (do + (when-not quiet? + (safe-println (if err? (colorize/red line) line))) + (recur (conj lines line))) + lines))) + +(defn- deref-with-timeout [dereffable timeout-ms] + (let [result (deref dereffable timeout-ms ::timed-out)] + (when (= result ::timed-out) + (throw (ex-info (format "Timed out after %d ms." timeout-ms) {}))) + result)) + +(def ^:private command-timeout-ms (* 5 60 1000)) ; 5 minutes + +(defn sh* + "Run a shell command. Like `clojure.java.shell/sh`, but prints output to stdout/stderr and returns results as a vector + of lines." + {:arglists '([cmd & args] [{:keys [dir quiet?]} cmd & args])} + [& args] + (step (colorize/blue (str "$ " (str/join " " (map (comp pr-str str) args)))) + (let [[opts & args] (if (map? (first args)) + args + (cons nil args)) + {:keys [dir]} opts + cmd-array (into-array (map str args)) + proc (.exec (Runtime/getRuntime) ^"[Ljava.lang.String;" cmd-array nil ^File (when dir (File. ^String dir)))] + (with-open [out-reader (BufferedReader. (InputStreamReader. (.getInputStream proc))) + err-reader (BufferedReader. (InputStreamReader. (.getErrorStream proc)))] + (let [exit-code (future (.waitFor proc)) + out (future (read-lines out-reader opts)) + err (future (read-lines err-reader (assoc opts :err? true)))] + {:exit (deref-with-timeout exit-code command-timeout-ms) + :out (deref-with-timeout out command-timeout-ms) + :err (deref-with-timeout err command-timeout-ms)}))))) + +(defn sh + "Run a shell command, returning its output if it returns zero or throwning an Exception if it returns non-zero." + {:arglists '([cmd & args] [{:keys [dir quiet?]} cmd & args])} + [& args] + (let [{:keys [exit out err], :as response} (apply sh* args)] + (if (zero? exit) + (concat out err) + (throw (ex-info (str/join "\n" (concat out err)) response))))) + +(defn- version* [] + (let [[out] (sh (assert-file-exists (str root-directory "/bin/version"))) + [_ version] (re-find #"^v([\d.]+)" out)] + (when-not (seq version) + (throw (ex-info "Error parsing version." {:out out}))) + version)) + +(def ^{:arglists '([])} version + "Currently tagged Metabase version. e.g. `0.34.3`" + (partial deref (delay (version*)))) + +(defn uploaded-artifact-url [artifact] + (format "https://downloads.metabase.com/v%s/%s" (version) artifact)) diff --git a/OSX/macos_release/create_dmg.clj b/OSX/macos_release/create_dmg.clj new file mode 100644 index 0000000000000000000000000000000000000000..d569627c002987ddda0866223e077c82b94efbec --- /dev/null +++ b/OSX/macos_release/create_dmg.clj @@ -0,0 +1,117 @@ +(ns macos-release.create-dmg + (:require [macos-release + [codesign :as codesign] + [common :as c]])) + +(def ^:private dmg (c/artifact "Metabase.dmg")) +(def ^:private temp-dmg "/tmp/Metabase.dmg") +(def ^:private source-dir "/tmp/Metabase.dmg.source") +(def ^:private mounted-dmg "/Volumes/Metabase") + +(defn- copy-app-to-source-dir! [] + (c/step "Copy app to source dir" + (c/delete-file! source-dir) + (c/create-directory-unless-exists! source-dir) + (let [source-app (c/assert-file-exists (c/artifact "Metabase.app")) + dest-app (str source-dir "/Metabase.app")] + (c/copy-file! source-app dest-app) + (c/assert-file-exists dest-app) + (codesign/verify-codesign dest-app)))) + +(defn- create-dmg-from-source-dir! [] + (c/delete-file! temp-dmg) + (c/step (format "Create DMG %s from source dir %s" temp-dmg source-dir) + (c/sh "hdiutil" "create" + "-srcfolder" (str (c/assert-file-exists source-dir) "/") + "-volname" "Metabase" + "-fs" "HFS+" + "-fsargs" "-c c=64,a=16,e=16" + "-format" "UDRW" + ;; has to be big enough to hold everything uncompressed, but doesn't matter if there's extra + ;; space -- compression slims it down + "-size" "512MB" + temp-dmg) + (c/announce "Created %s." temp-dmg))) + +(defn- mount-dmg! [dmg {:keys [readonly?] + :or {readonly? false}}] + (c/step (format "Mount %s -> %s" (c/assert-file-exists dmg) mounted-dmg) + (let [[out] (c/sh "hdiutil" "attach" + (if readonly? "-readonly" "-readwrite") + "-noverify" + "-noautoopen" dmg) + [_ device] (re-find #"(/dev/disk\d+)" out)] + device))) + +(defn- unmount-dmg! [device] + (c/step (format "Unmount device %s" device) + (letfn [(unmount! [] + ;; force completion of any pending disk writes + (c/sh "sync") + (c/sh "sync") + (c/sh "hdiutil" "detach" device))] + (try + (unmount!) + (catch Throwable _ + ;; if the unmount fails at first because the device is "busy" wait a few seconds and try again + (c/announce "Wait a bit for DMG to stop being 'busy'") + (Thread/sleep 5000) + (unmount!)))))) + +(defn- do-with-mounted-dmg [dmg options f] + (c/step (format "Mount %s" dmg) + (let [device (mount-dmg! dmg options)] + (try + (f device) + (finally + (unmount-dmg! device)))))) + +(defmacro ^:private with-mounted-dmg [[device-binding dmg options] & body] + `(do-with-mounted-dmg ~dmg ~options (fn [~device-binding] ~@body))) + +(defn- add-applications-shortcut! [] + (c/assert-file-exists mounted-dmg) + (c/sh "osascript" (c/assert-file-exists (str c/macos-source-dir "/macos_release/addShortcut.scpt")))) + +(defn- delete-temporary-files-in-dmg! + "Delete any temporary files that might have creeped in." + [] + (c/assert-file-exists mounted-dmg) + (c/delete-file! (str mounted-dmg "/.Trashes") + (str mounted-dmg "/.fseventsd"))) + +(defn- set-dmg-permissions! [] + (c/sh "chmod" "-Rf" "go-w" (c/assert-file-exists mounted-dmg))) + +(defn- verify-dmg-codesign! [] + (codesign/verify-codesign (str mounted-dmg "/Metabase.app"))) + +(defn- compress-and-copy-dmg! + [] + (c/delete-file! dmg) + (c/step (format "Compress DMG %s -> %s" (c/assert-file-exists temp-dmg) dmg) + (c/sh "hdiutil" "convert" temp-dmg + "-format" "UDZO" + "-imagekey" "zlib-level-9" + "-o" dmg) + (c/assert-file-exists dmg))) + +(defn- delete-temp-files! [] + (c/step "Delete temp files" + (c/delete-file! temp-dmg source-dir))) + +(defn create-dmg! [] + (c/step (format "Create %s" dmg) + (c/delete-file! dmg temp-dmg source-dir) + (copy-app-to-source-dir!) + (create-dmg-from-source-dir!) + (with-mounted-dmg [_ temp-dmg] + (add-applications-shortcut!) + (delete-temporary-files-in-dmg!) + (set-dmg-permissions!) + (verify-dmg-codesign!)) + (compress-and-copy-dmg!) + (delete-temp-files!) + (with-mounted-dmg [_ dmg {:readonly? true}] + (verify-dmg-codesign!)) + (c/announce "Successfully created %s." dmg))) diff --git a/OSX/macos_release/notarize.clj b/OSX/macos_release/notarize.clj new file mode 100644 index 0000000000000000000000000000000000000000..3779883940be312a7c0c14994b4660fa25198e77 --- /dev/null +++ b/OSX/macos_release/notarize.clj @@ -0,0 +1,114 @@ +(ns macos-release.notarize + (:require [cheshire.core :as json] + [clj-http.client :as http] + [clojure.string :as str] + [environ.core :as env] + [macos-release.common :as c])) + +(def ^:private asc-provider "BR27ZJK7WW") + +(defn- apple-id [] + (or (env/env :metabase-mac-app-build-apple-id) + (throw (ex-info "Please set the METABASE_MAC_APP_BUILD_APPLE_ID env var." {})))) + +(def ^:private keychain-password "@keychain:METABASE_MAC_APP_BUILD_PASSWORD") + +(defn- notarize-file! + "Returns request UUID." + [filename] + (c/step (format "Notarize %s" (c/assert-file-exists filename)) + (let [lines (c/sh "xcrun" "altool" "--notarize-app" + "--primary-bundle-id" "com.metabase.Metabase" + "--username" (apple-id) + "--password" keychain-password + "--asc-provider" asc-provider + "--file" filename)] + (some (fn [line] + (when-let [[_ uuid] (re-matches #"RequestUUID = ([\w-]+)" line)] + uuid)) + lines)))) + +(defn- notarization-status + "Returns a map with string keys like `LogFileURL` and `Status`." + [uuid] + (reduce (fn [m line] + (if-let [[_ k v] (re-matches #"^(.+):\s+(.+)$" line)] + (assoc m (str/trim k) (str/trim v)) + m)) + {} + (c/sh "xcrun" "altool" "--notarization-info" uuid + "-u" (apple-id) + "-p" keychain-password + "-asc-provider" asc-provider))) + +(def ^:private notarization-timeout-ms (* 5 60 1000)) ; five minutes + +(defn notarization-log-info + "Comes back as a map." + [url] + (-> (http/get url) + :body + (json/parse-string true))) + +(defn- wait-for-notarization [uuid] + (c/step (format "Wait for notarization for %s" uuid) + (let [start-time (System/currentTimeMillis)] + (loop [] + (let [duration (- (System/currentTimeMillis) start-time)] + (when (> duration notarization-timeout-ms) + (throw (ex-info "Notarization timed out." {})))) + (let [{status "Status", log-url "LogFileURL", :as status-map} (notarization-status uuid)] + (condp = status + "in progress" + (do (Thread/sleep 5000) + (recur)) + + "success" + (c/announce "Notarization successful.") + + (let [error-info (try + (some-> log-url notarization-log-info) + (catch Throwable e + (locking println (println "Error fetching log info:" e)) + nil))] + (try + (some->> log-url (c/sh "open")) + (catch Throwable _)) + (throw (ex-info "Notarization error." + {:uuid uuid + :status status-map + :log-info error-info}))))))))) + +(defn- staple-notarization! [filename] + (c/step (format "Staple notarization to %s" (c/assert-file-exists filename)) + (c/sh "xcrun" "stapler" "staple" "-v" filename) + (c/announce "Notarization stapled successfully."))) + +(defn- verify-notarization + "Verify that an app is Signed & Notarized correctly. See https://help.apple.com/xcode/mac/current/#/dev1cc22a95c" + [filename] + (c/step (format "Verify notarization for %s" (c/assert-file-exists filename)) + (let [source (some (fn [line] + (when-let [[_ source] (re-matches #"^source=(.+)$" line)] + (str/trim source))) + (c/sh "spctl" "-a" "-v" filename))] + (assert (= source "Notarized Developer ID") (format "Unexpected source: %s" (pr-str source)))) + (c/announce "Verification successful."))) + +(defn notarize! [] + (c/step "Notarize" + (let [dmg-request-uuid (notarize-file! (c/artifact "Metabase.dmg")) + zip-request-uuid (notarize-file! (c/artifact "Metabase.zip"))] + (wait-for-notarization dmg-request-uuid) + (wait-for-notarization zip-request-uuid)) + (staple-notarization! (c/artifact "Metabase.dmg")) + (verify-notarization (c/artifact "Metabase.app")))) + +(defn- notarization-history + "Provided primarily for REPL usage." + [] + (c/sh "xcrun" "altool" + "--notarization-history" "0" + "-u" (apple-id) + "-p" keychain-password + "--asc-provider" asc-provider)) diff --git a/OSX/macos_release/sparkle_artifacts.clj b/OSX/macos_release/sparkle_artifacts.clj new file mode 100644 index 0000000000000000000000000000000000000000..7d273c3c565d993a4bb074fe0441d508082b72d7 --- /dev/null +++ b/OSX/macos_release/sparkle_artifacts.clj @@ -0,0 +1,131 @@ +(ns macos-release.sparkle-artifacts + (:require [clojure.data.xml :as xml] + [clojure.java.io :as io] + [clojure.string :as str] + [hiccup.core :as h] + [macos-release + [codesign :as codesign] + [common :as c]] + [pl.danieljanus.tagsoup :as tagsoup]) + (:import [java.io File FileOutputStream OutputStreamWriter] + java.nio.charset.StandardCharsets)) + +(def ^:private ^String appcast-file (c/artifact "appcast.xml")) +(def ^:private ^String release-notes-file (c/artifact "release-notes.html")) +(def ^:private ^String zip-file (c/artifact "Metabase.zip")) + +(defn- verify-zip-codesign [] + (c/step (format "Verify code signature of Metabase.app archived in %s" zip-file) + (let [temp-file "/tmp/Metabase.zip"] + (c/delete-file! temp-file) + (c/sh {:quiet? true} + "unzip" (c/assert-file-exists zip-file) + "-d" temp-file) + (c/assert-file-exists temp-file) + (let [unzipped-app-file (c/assert-file-exists (str temp-file "/Metabase.app"))] + (codesign/verify-codesign unzipped-app-file))))) + +(defn- create-zip-archive! [] + (c/delete-file! zip-file) + (c/step (format "Create ZIP file %s" zip-file) + (c/assert-file-exists (c/artifact "Metabase.app")) + ;; Use ditto instead of zip to preserve the codesigning -- see https://forums.developer.apple.com/thread/116831 + (c/sh {:dir c/artifacts-directory} + "ditto" "-c" "-k" "--sequesterRsrc" + "--keepParent" "Metabase.app" "Metabase.zip") + (c/assert-file-exists zip-file) + (verify-zip-codesign))) + +(defn- generate-file-signature [filename] + (c/step (format "Generate signature for %s" filename) + (let [private-key (c/assert-file-exists (str c/macos-source-dir "/dsa_priv.pem")) + script (c/assert-file-exists (str c/root-directory "/bin/lib/sign_update.rb")) + [out] (c/sh script (c/assert-file-exists filename) private-key) + signature (str/trim out)] + (assert (seq signature)) + signature))) + +(defn- handle-namespaced-keyword [k] + (if (namespace k) + (str (namespace k) ":" (name k)) + k)) + +(defn- xml [form] + (if (and (sequential? form) + (keyword? (first form))) + (let [[element & more] form + [attrs & body] (if (map? (first more)) + more + (cons {} more)) + attrs (into {} (for [[k v] attrs] + [(handle-namespaced-keyword k) v])) + element (handle-namespaced-keyword element)] + (apply xml/element element attrs (map xml body))) + form)) + +(defn- appcast-xml + ([] + (appcast-xml (.length (File. (c/assert-file-exists zip-file))) + (generate-file-signature zip-file))) + + ([length signature] + (xml + [:rss + {:version "2.0" + :xmlns/sparkle "http://www.andymatuschak.org/xml-namespaces/sparkle" + :xmlns/dc "http://purl.org/dc/elements/1.1/"} + [:channel + [:title "Metabase ChangeLog"] + [:link (c/uploaded-artifact-url "appcast.xml")] + [:language "en"] + [:item + [:title (format "Version %s" (c/version))] + [:sparkle/releaseNotesLink (c/uploaded-artifact-url "release-notes.html")] + [:enclosure + {:url (c/uploaded-artifact-url "Metabase.zip") + :sparkle/version (c/version) + :length length + :type "application/octet-stream" + :sparkle/dsaSignature signature}]]]]))) + +(defn- generate-appcast! [] + (c/delete-file! appcast-file) + (c/step (format "Generate appcast %s" appcast-file) + (with-open [os (FileOutputStream. (File. appcast-file)) + w (OutputStreamWriter. os StandardCharsets/UTF_8)] + (xml/indent (appcast-xml) w)) + (c/assert-file-exists appcast-file))) + +(defn- release-notes-body [] + (let [url (format "https://github.com/metabase/metabase/releases/tag/v%s" (c/version))] + (try + (letfn [(find-body [x] + (when (sequential? x) + (let [[element {klass :class} & body] x] + (if (and (= element :div) + (= klass "markdown-body")) + x + (some find-body body)))))] + (->> (tagsoup/parse url) + find-body)) + (catch Throwable e + (throw (ex-info (format "Error parsing release notes at %s: are you sure they exists?" url) {} e)))))) + +(defn- release-notes [] + [:html + [:head [:title "Metabase Release Notes"]] + [:body (release-notes-body)]]) + +(defn- generate-release-notes! [] + (c/delete-file! release-notes-file) + (c/step (format "Generate release notes %s" release-notes-file) + (let [notes (release-notes)] + (with-open [w (io/writer release-notes-file)] + (.write w (h/html notes)))))) + +(defn generate-sparkle-artifacts! [] + (c/step "Generate Sparkle artifacts" + (create-zip-archive!) + (generate-appcast!) + (generate-release-notes!) + (c/announce "Sparkle artifacts generated successfully."))) diff --git a/OSX/macos_release/upload.clj b/OSX/macos_release/upload.clj new file mode 100644 index 0000000000000000000000000000000000000000..48ea818fa1d6defe188d343de77f1ad56fec510d --- /dev/null +++ b/OSX/macos_release/upload.clj @@ -0,0 +1,46 @@ +(ns macos-release.upload + (:require [macos-release.common :as c])) + +(def ^:private aws-profile "metabase") +(def ^:private s3-bucket "downloads.metabase.com") +(def ^:private cloudfront-distribution-id "E35CJLWZIZVG7K") + +(def ^:private upload-dir (c/artifact "upload")) + +(defn- copy-files-to-upload-dir! [] + (c/delete-file! upload-dir) + (c/step (format "Copy files to %s" upload-dir) + (c/step "Copy top-level files" + (c/create-directory-unless-exists! upload-dir) + (c/copy-file! (c/assert-file-exists (c/artifact "appcast.xml")) (str upload-dir "/appcast.xml"))) + (let [version-upload-dir (str upload-dir "/v" (c/version))] + (c/step (format "Copy files to %s" version-upload-dir) + (c/create-directory-unless-exists! version-upload-dir) + (doseq [file ["Metabase.zip" "Metabase.dmg" "release-notes.html"]] + (c/copy-file! (c/assert-file-exists (c/artifact file)) (str version-upload-dir "/" file))))))) + +(defn- upload-artifacts! [] + (c/step "Upload artifacts to https://downloads.metabase.com" + (c/sh "aws" "--recursive" + "--profile" aws-profile + "--region" "us-east-1" + "s3" "cp" upload-dir + (format "s3://%s" s3-bucket)) + (c/announce "All files uploaded."))) + +(defn- create-cloudfront-invalidation! [] + (c/step "Create CloudFront invalidation" + (c/step "Enable CloudFront CLI" + (c/sh "aws" "configure" "set" "preview.cloudfront" "true")) + (c/step "Invalidate /appcast.xml" + (c/sh "aws" "cloudfront" "create-invalidation" + "--profile" aws-profile + "--distribution-id" cloudfront-distribution-id + "--paths" "/appcast.xml")) + (c/announce "Invalidation created successfully."))) + +(defn upload! [] + (c/step "Upload artifacts" + (copy-files-to-upload-dir!) + (upload-artifacts!) + (create-cloudfront-invalidation!))) diff --git a/bin/Metabase/Util.pm b/bin/Metabase/Util.pm deleted file mode 100644 index 72c0e10dc95d4724bb7af99e47cf2db0ef93bf30..0000000000000000000000000000000000000000 --- a/bin/Metabase/Util.pm +++ /dev/null @@ -1,84 +0,0 @@ -use strict; -use warnings; - -package Metabase::Util; - -use Cwd 'getcwd'; -use Exporter; -use JSON; -use Readonly; -use Term::ANSIColor qw(:constants); - -our @ISA = qw(Exporter); -our @EXPORT = qw(config - config_or_die - announce - print_giant_success_banner - get_file_or_die - plist_buddy_exec - OSX_ARTIFACTS_DIR - artifact); - -Readonly my $config_file => getcwd() . '/bin/config.json'; -warn "Missing config file: $config_file\n" . - "Please copy $config_file.template, and edit it as needed.\n" - unless (-e $config_file); -Readonly my $config => from_json(`cat $config_file`) if -e $config_file; - -sub config { - return $config ? $config->{ $_[0] } : ''; -} - -sub config_or_die { - my ($configKey) = @_; - return config($configKey) or die "Missing config.json property '$configKey'"; -} - -sub announce { - print "\n\n" . GREEN . $_[0] . RESET . "\n\n"; -} - -sub print_giant_success_banner { - print "\n\n". BLUE . - '+----------------------------------------------------------------------+' . "\n" . - '| |' . "\n" . - '| _______ _______ _______ _______ _______ _______ _ |' . "\n" . - '| ( ____ \|\ /|( ____ \( ____ \( ____ \( ____ \( ____ \( ) |' . "\n" . - '| | ( \/| ) ( || ( \/| ( \/| ( \/| ( \/| ( \/| | |' . "\n" . - '| | (_____ | | | || | | | | (__ | (_____ | (_____ | | |' . "\n" . - '| (_____ )| | | || | | | | __) (_____ )(_____ )| | |' . "\n" . - '| ) || | | || | | | | ( ) | ) |(_) |' . "\n" . - '| /\____) || (___) || (____/\| (____/\| (____/\/\____) |/\____) | _ |' . "\n" . - '| \_______)(_______)(_______/(_______/(_______/\_______)\_______)(_) |' . "\n" . - '| |' . "\n" . - '| |' . "\n" . - '+----------------------------------------------------------------------+' . RESET . "\n\n"; -} - -# Check if a file exists, or die. -# If file path is relative, qualify it by prepending the current dir. -# Return the fully-qualified file path. -sub get_file_or_die { - my ($filename) = @_; - $filename = (getcwd() . '/' . $filename) if $filename !~ /^\//; - - die "Error: $filename does not exist.\n" unless -e $filename; - - return $filename; -} - -# Run a PlistBuddy command against Metabase-Info.plist -sub plist_buddy_exec { - my $info_plist = get_file_or_die('OSX/Metabase/Metabase-Info.plist'); - return `/usr/libexec/PlistBuddy -c '@_' "$info_plist"`; -} - -use constant OSX_ARTIFACTS_DIR => getcwd() . '/osx-artifacts'; -sub artifact { - # Make the artifacts directory if needed - system('mkdir', OSX_ARTIFACTS_DIR) if ! -d OSX_ARTIFACTS_DIR; - - return OSX_ARTIFACTS_DIR . "/$_[0]"; -} - -1; diff --git a/bin/config.json b/bin/config.json deleted file mode 100644 index bb2a29d80f4e81ef10a990c58a6e755aacf02e0f..0000000000000000000000000000000000000000 --- a/bin/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "codesigningIdentity": "Developer ID Application: Metabase, Inc", - "appStoreConnectProviderShortName": "BR27ZJK7WW", - "awsProfile": "metabase", - "awsBucket": "downloads.metabase.com", - "cloudFrontDistributionID": "E35CJLWZIZVG7K" -} diff --git a/bin/osx-release b/bin/osx-release deleted file mode 100755 index 3660b92c98950bbe0c5fde5f3e71750b2347339b..0000000000000000000000000000000000000000 --- a/bin/osx-release +++ /dev/null @@ -1,491 +0,0 @@ -#! /usr/bin/env perl -I./bin - -use strict; -use warnings; - -use Cwd 'getcwd'; -use File::Copy 'copy'; -use File::Copy::Recursive 'rcopy'; # CPAN -use File::Path 'remove_tree'; -use File::stat 'stat'; -use Readonly; # CPAN -use String::Util 'trim'; # CPAN -use Text::Caml; # CPAN - -use Metabase::Util; - -Readonly my $app => artifact('Metabase.app'); -Readonly my $zipfile => artifact('Metabase.zip'); -Readonly my $appcast => artifact('appcast.xml'); -Readonly my $release_notes => artifact('release-notes.html'); -Readonly my $dmg => artifact('Metabase.dmg'); - -Readonly my $xcode_project => get_file_or_die('OSX/Metabase.xcodeproj'); -Readonly my $export_options => get_file_or_die('OSX/exportOptions.plist'); - -# Get the version saved in the CFBundle, e.g. '0.11.3.1' -sub version { - return trim(plist_buddy_exec('Print', 'CFBundleVersion')); -} - -# Get the tag saved in version.properties, e.g. '0.12.0' -sub version_from_props_file { - open(FILE, get_file_or_die('resources/version.properties')) or die $!; - while (<FILE>) { m/^tag/ && s/^tag=v([0-9.]+)[^0-9.]*$/$1/ && (return trim($_)); }; -} - -# This is the name of the subdirectory on s3, e.g. 'v.0.12.0' -sub upload_subdir { - return 'v' . version_from_props_file(); -} - -# Next version after version(), e.g. '0.11.3.2' -sub next_version { - my ($old_version_tag, $old_version_point_release) = (version() =~ /^(\d+\.\d+\.\d+)\.(\d+)$/); - - Readonly my $tag_from_props_file => version_from_props_file(); - - # Now calculate the new version, which is ($tag.$point_release) - # Check and see if tag has changed in version.properties; if so, new version is the first "point release" of that tag. - return $old_version_tag eq $tag_from_props_file ? ($old_version_tag . '.' . ($old_version_point_release + 1)) : "$tag_from_props_file.0"; -} - -sub bump_version { - Readonly my $new_version => next_version(); - announce 'Bumping version: ' . version() . " -> $new_version"; - - plist_buddy_exec('Set', ':CFBundleVersion', $new_version); - plist_buddy_exec('Set', ':CFBundleShortVersionString', $new_version); -} - -sub clean { - system('xcodebuild', '-UseNewBuildSystem=NO', 'clean', '-project', $xcode_project) == 0 or die $!; - remove_tree(OSX_ARTIFACTS_DIR); -} - -# Build Metabase.app -sub build { - announce "Building $app..."; - - Readonly my $xcarchive => artifact('Metabase.xcarchive'); - - # remove old artifacts if they exist - remove_tree($xcarchive, $app); - - # Build the project and generate Metabase.xcarchive - system('xcodebuild', - '-UseNewBuildSystem=NO', - '-project', $xcode_project, - '-scheme', 'Metabase', - '-configuration', 'Release', - '-archivePath', $xcarchive, - 'archive') == 0 or die $!; - - # Ok, now create the Metabase.app artifact - system('xcodebuild', - '-UseNewBuildSystem=NO', - '-exportArchive', - '-exportOptionsPlist', $export_options, - '-archivePath', $xcarchive, - '-exportPath', OSX_ARTIFACTS_DIR) == 0 or die $!; - - # Ok, we can remove the .xcarchive file now - remove_tree($xcarchive); -} - -sub codesign_file { - my ($filename) = @_; - - Readonly my $codesigning_cert_name => config_or_die('codesigningIdentity'); - Readonly my $entitlements_file => get_file_or_die('OSX/Metabase/Metabase.entitlements'); - - announce "Codesigning $filename..."; - - system('codesign', '--force', '--verify', - '--sign', $codesigning_cert_name, - '-r=designated => anchor trusted', - '--timestamp', - '--options', 'runtime', - '--entitlements', $entitlements_file, - '--deep', get_file_or_die($filename)) == 0 or die "Code signing failed: $!\n"; -} - -# Codesign Metabase.app -sub codesign { - codesign_file($app) or die $1; -} - -sub verify_file_codesign { - my ($filename) = @_; - get_file_or_die($filename); - - config_or_die('codesigningIdentity'); - - announce "Verifying codesigning for $filename..."; - - system('codesign', '--verify', '--deep', - '--display', - '--strict', - '--verbose=4', - get_file_or_die($filename)) == 0 or die "Code signing verification failed: $!\n"; - - announce "codesign --verify $filename successful"; - - # Double-check with System Policy Security tool - system('spctl', '--assess', '--verbose=4', get_file_or_die($filename)) == 0 or die "Codesigning verification (spctl) failed: $!\n"; - - announce "spctl --assess $filename successful"; - -} - -# Verify that Metabase.app was signed correctly -sub verify_codesign { - verify_file_codesign($app) or die $!; -} - - -# ------------------------------------------------------------ PACKAGING FOR SPARKLE ------------------------------------------------------------ - -sub verify_zip_codesign { - remove_tree('/tmp/Metabase.zip'); - - system('unzip', get_file_or_die($zipfile), - '-d', '/tmp/Metabase.zip'); - - verify_file_codesign('/tmp/Metabase.zip/Metabase.app') or die $!; -} - -# Create ZIP containing Metabase.app -sub archive { - announce "Creating $zipfile..."; - - remove_tree($zipfile); - - get_file_or_die($app); - - # Use ditto instead of zip to preserve the codesigning -- see https://forums.developer.apple.com/thread/116831 - system('cd ' . OSX_ARTIFACTS_DIR . ' && ditto -c -k --sequesterRsrc --keepParent Metabase.app Metabase.zip') == 0 or die $!; - get_file_or_die($zipfile); - - verify_zip_codesign; -} - -sub generate_signature { - Readonly my $private_key_file => getcwd() . '/OSX/dsa_priv.pem'; - - unless (-e $private_key_file) { - warn "Missing private key file: $private_key_file\n"; - return; - } - - Readonly my $sign_update_script => get_file_or_die('bin/lib/sign_update.rb'); - - get_file_or_die($zipfile); - - return trim(`$sign_update_script "$zipfile" "$private_key_file"`); -} - -# Generate the appcast.xml RSS feed file that Sparkle reads to check for updates -sub generate_appcast { - announce "Generating $appcast..."; - - remove_tree($appcast); - - Readonly my $aws_bucket => config_or_die('awsBucket'); - Readonly my $signature => generate_signature() or die 'Failed to generate appcast signature'; - - open(my $out, '>', $appcast) or die "Unable to write to $appcast: $!"; - print $out Text::Caml->new->render_file(get_file_or_die('bin/templates/appcast.xml.template'), { - VERSION => version(), - SIGNATURE => $signature, - LENGTH => stat(get_file_or_die($zipfile))->size, - S3_BUCKET => $aws_bucket, - S3_SUBDIR => upload_subdir() - }); - close $out; -} - -sub edit_release_notes { - remove_tree($release_notes); - - copy(get_file_or_die('bin/templates/release-notes.html.template'), $release_notes) or die $!; - system('nano', get_file_or_die($release_notes)) == 0 or die $!; -} - - -# ------------------------------------------------------------ CREATING DMG ------------------------------------------------------------ - -sub create_dmg_from_source_dir { - my ($source_dir, $dmg_filename) = @_; - announce "Creating DMG: $dmg_filename from source $source_dir..."; - - system('hdiutil', 'create', - '-srcfolder', $source_dir, - '-volname', 'Metabase', - '-fs', 'HFS+', - '-fsargs', '-c c=64,a=16,e=16', - '-format', 'UDRW', - '-size', '512MB', # has to be big enough to hold everything uncompressed, but doesn't matter if there's extra space -- compression slims it down - $dmg_filename) == 0 or die $!; - - announce "$dmg_filename created."; -} - -# Mount the disk image, return the device name -sub mount_dmg { - my ($dmg_filename) = @_; - announce "Mounting DMG..."; - - my $device = `hdiutil attach -readwrite -noverify -noautoopen $dmg_filename`; - # Find the device name: the part of the output looks like /dev/disk11 - for my $token (split(/\s+/, $device)) { - if ($token =~ m|^/dev/|) { - $device = $token; - last; - } - } - - announce "Mounted $dmg_filename at $device"; - return $device; -} - -sub dmg_add_applications_shortcut { - announce "Adding shortcut to /Applications..."; - - system('osascript', '-e', - 'tell application "Finder" - tell disk "Metabase" - open - set current view of container window to icon view - set toolbar visible of container window to false - set statusbar visible of container window to false - set the bounds of container window to {400, 100, 885, 430} - set theViewOptions to the icon view options of container window - set arrangement of theViewOptions to not arranged - set icon size of theViewOptions to 72 - make new alias file at container window to POSIX file "/Applications" with properties {name:"Applications"} - set position of item "Metabase.app" of container window to {100, 100} - set position of item "Applications" of container window to {375, 100} - update without registering applications - delay 5 - close - end tell - end tell') == 0 or die $!; -} - -sub finalize_dmg { - my ($device, $dmg_filename) = @_; - announce "Finalizing DMG..."; - - # Remove any hidden files that creeped into the DMG - remove_tree('/Volumes/Metabase/.Trashes', - '/Volumes/Metabase/.fseventsd'); - - - # Set DMG permissions, force completion of pending disk writes - system('chmod', '-Rf', 'go-w', '/Volumes/Metabase') == 0 or warn $!; # this might issue warnings about not being able to affect .Trashes - system('sync'); - system('sync'); - - # wait a few seconds for the sync to complete so DMG isn't "busy" when we try to unmount it - system('sleep', '5'); - - # unmount the temp DMG - announce "Unmounting $device..."; - system('hdiutil', 'detach', $device) == 0 or die $!; - - # compress the DMG - announce "Compressing DMG..."; - system('hdiutil', 'convert', $dmg_filename, - '-format', 'UDZO', - '-imagekey', 'zlib-level-9', - '-o', $dmg) == 0 or die $!; -} - -sub create_dmg { - announce "Preparing DMG files..."; - - # detach any existing Metabase DMGs - system('hdiutil', 'detach', '/Volumes/Metabase') if -d '/Volumes/Metabase'; - - Readonly my $temp_dmg => artifact('Metabase.temp.dmg'); - Readonly my $dmg_source_dir => artifact('dmg'); - - # Clean up old artifacts - remove_tree($dmg_source_dir, $temp_dmg, $dmg); - mkdir $dmg_source_dir or die $!; - - # Copy Metabase.app into the source dir - rcopy(get_file_or_die($app), $dmg_source_dir . '/Metabase.app') or die $!; - - # Ok, now proceed with the steps to create the DMG - create_dmg_from_source_dir($dmg_source_dir, $temp_dmg); - - Readonly my $device => mount_dmg($temp_dmg); - - dmg_add_applications_shortcut; - - finalize_dmg($device, $temp_dmg); - - announce "DMG created successfully: $dmg"; - - # Cleanup: remove temp file & temp dir - remove_tree($temp_dmg, $dmg_source_dir); -} - -# ------------------------------------------------------------ NOTORIZATION ------------------------------------------------------------ - -sub getAppleID { - return $ENV{'METABASE_MAC_APP_BUILD_APPLE_ID'} or die 'Make sure you export the env var METABASE_MAC_APP_BUILD_APPLE_ID'; -} - -sub getAscProvider { - return config_or_die('appStoreConnectProviderShortName'); -} - -sub notarize_file { - my ($filename) = @_; - - announce "Notarizing $filename..."; - - Readonly my $appleID => getAppleID; - Readonly my $ascProvider => getAscProvider; - - system('xcrun', 'altool', '--notarize-app', - '--primary-bundle-id', 'com.metabase.Metabase', - '--username', $appleID, - '--password', '@keychain:METABASE_MAC_APP_BUILD_PASSWORD', - '--asc-provider', $ascProvider, - '--file', $filename - ) == 0 or die $!; - - print 'You can keep an eye on the notarization status (and get the LogFileURL) with the command:' . "\n\n"; - print ' xcrun altool --notarization-info <RequestUUID> -u "$METABASE_MAC_APP_BUILD_APPLE_ID" -p "@keychain:METABASE_MAC_APP_BUILD_PASSWORD"' . "\n\n"; -} - -sub wait_for_notarization { - announce "Waiting for notarization..."; - - Readonly my $appleID => getAppleID; - Readonly my $ascProvider => getAscProvider; - - my $status = `xcrun altool --notarization-history 0 -u "$appleID" -p "\@keychain:METABASE_MAC_APP_BUILD_PASSWORD" --asc-provider $ascProvider` or die $!; - - print "$status\n"; - - if ($status =~ m/in progress/) { - print "Notarization is still in progress, waiting a few seconds and trying again...\n"; - sleep 5; - wait_for_notarization(); - } else { - announce "Notarization successful."; - return "Done"; - } -} - -sub staple_notorization { - my ($filename) = @_; - - announce "Stapling notarization to $filename..."; - - system('xcrun', 'stapler', 'staple', - '-v', $filename) == 0 or die $!; - - announce "Notarization stapled successfully."; -} - -# Verify that an app is Signed & Notarized correctly. See https://help.apple.com/xcode/mac/current/#/dev1cc22a95c -sub verify_notarization { - # e.g. /Applications/Metabase.app - my ($appFile) = @_; - - announce "Verifying that $appFile is notarized correctly..."; - - system('spctl', '-a', '-v', $appFile) == 0 or die $!; - - announce "Verification successful."; -} - - -sub notarize_files { - notarize_file(get_file_or_die($zipfile)); - notarize_file(get_file_or_die($dmg)); - wait_for_notarization(); - staple_notorization(get_file_or_die($dmg)); - verify_notarization(get_file_or_die($app)); -} - - -# ------------------------------------------------------------ UPLOADING ------------------------------------------------------------ - - -# Upload artifacts to AWS -# Make sure to run `aws configure --profile metabase` first to set up your ~/.aws/config file correctly -sub upload { - Readonly my $aws_profile => config_or_die('awsProfile'); - Readonly my $aws_bucket => config_or_die('awsBucket'); - - # Make a folder that contains the files we want to upload - Readonly my $upload_dir => artifact('upload'); - remove_tree($upload_dir) if -d $upload_dir; - mkdir $upload_dir or die $!; - - # appcast.xml goes in the root directory - copy(get_file_or_die($appcast), $upload_dir) or die $!; - - # zipfile, release notes, and DMG go in a dir like v0.12.0 - Readonly my $upload_subdir => $upload_dir . '/' . upload_subdir(); - mkdir $upload_subdir or die $!; - - for my $file ($zipfile, $release_notes, $dmg) { - copy(get_file_or_die($file), $upload_subdir) or die $!; - } - - announce "Uploading files to $aws_bucket..."; - system('aws', '--recursive', - '--profile', $aws_profile, - '--region', 'us-east-1', - 's3', 'cp', $upload_dir, - "s3://$aws_bucket") == 0 or die "Upload failed: $!\n"; - - announce "Upload finished." -} - -sub create_cloudfront_invalidation { - announce "Creating CloudFront invalidation..."; - - system ('aws', 'configure', - 'set', 'preview.cloudfront', 'true') == 0 or die $!; - - system ('aws', 'cloudfront', 'create-invalidation', - '--profile', config_or_die('awsProfile'), - '--distribution-id', config_or_die('cloudFrontDistributionID'), - '--paths', '/appcast.xml') == 0 or die $!; - - announce "CloudFront invalidation created successfully."; -} - - -# ------------------------------------------------------------ RUN ALL ------------------------------------------------------------ - -sub all { - clean; - bump_version; - build; - codesign; - verify_codesign; - archive; - generate_appcast; - edit_release_notes; - create_dmg; - notarize_files; - upload; - create_cloudfront_invalidation; -} - -# Run all the commands specified in command line args, otherwise default to running 'all' -@ARGV = ('all') unless @ARGV; -no strict 'refs'; -map { $_->(); } @ARGV; - -print_giant_success_banner(); diff --git a/docs/developers-guide-osx.md b/docs/developers-guide-osx.md index f27b2b38b6c3ed367053ed362377ecbc4c412507..d297cdac9424dce07ba430ee77f864dccdbca444 100644 --- a/docs/developers-guide-osx.md +++ b/docs/developers-guide-osx.md @@ -36,104 +36,90 @@ The following steps need to be done before building the Mac App: Assuming the OpenJDK folks have resolved this issue going forward, you are fine to use whatever the latest JRE version available is. I have been using the HotSpot JRE instead of the OpenJ9 one but it ultimately shouldn't make a difference. - -1. Copy Metabase uberjar to OSX resources dir +1) Copy Metabase uberjar to OSX resources dir ```bash cp /path/to/metabase.jar OSX/Resources/metabase.jar ``` - + Every time you want to build a new version of the Mac App, you can simple update the bundled uberjar the same way. - + At this point, you should try opening up the Xcode project and building the Mac App in Xcode by clicking the run button. The app should build and launch at this point. If it doesn't, ask Cam for help! ### Releasing The following steps are prereqs for releasing the Mac App: - 1. Install XCode command-line tools. In `Xcode` > `Preferences` > `Locations` select your current Xcode version in the `Command Line Tools` drop-down. -1. Install CPAN modules - - ```bash - sudo cpan - install force File::Copy::Recursive Readonly String::Util Text::Caml JSON - quit - ``` - - You can install [PerlBrew](https://perlbrew.pl/) if you want to install CPAN modules without having to use `sudo`. - - Normally you shouldn't have to use `install force` to install the modules above, but `File::Copy::Recursive` seems fussy lately and has a failing test that prevents it from installing normally. - -1. Install AWS command-line client (if needed) +1) Install AWS command-line client (if needed) ```bash brew install awscli ``` - -1. Configure AWS Credentials for `metabase` profile (used to upload artifacts to S3) + +1) Configure AWS Credentials for `metabase` profile (used to upload artifacts to S3) You'll need credentials that give you permission to write the metabase-osx-releases S3 bucket. You just need the access key ID and secret key; use the defaults for locale and other options. - + ```bash aws configure --profile metabase ``` - -1. Obtain a copy of the private key for signing app updates (ask Cam) and put a copy of it at `OSX/dsa_priv.pem` + +1) Obtain a copy of the private key for signing app updates (ask Cam) and put a copy of it at `OSX/dsa_priv.pem` ```bash cp /path/to/private/key.pem OSX/dsa_priv.pem ``` - -1. Add `Apple Developer ID Application Certificate` to your computer's keychain. + +1) Add `Apple Developer ID Application Certificate` to your computer's keychain. You'll need to generate a Certificate Signing Request from Keychain Access, and have Sameer go to [the Apple Developer Site](https://developer.apple.com/account/mac/certificate/) and generate one for you, then load the file on your computer. - -1. Export your Apple ID for building the app as `METABASE_MAC_APP_BUILD_APPLE_ID`. (This Apple ID must be part of the Metabase org in the Apple developer site. Ask Cam or Sameer to add you if it isn't.) - + +1) Export your Apple ID for building the app as `METABASE_MAC_APP_BUILD_APPLE_ID`. (This Apple ID must be part of the Metabase org in the Apple developer site. Ask Cam or Sameer to add you if it isn't.) + ```bash # Add this to .zshrc or .bashrc export METABASE_MAC_APP_BUILD_APPLE_ID=my_email@whatever.com - ``` + ``` -1. Create an App-Specific password for the Apple ID in the previous step +1) Create an App-Specific password for the Apple ID in the previous step + + Go to https://appleid.apple.com/account/manage then `Security` > `App-Specific Passwords` > `Generate Password` - Go to https://appleid.apple.com/account/manage then `Security` > `App-Specific Passwords` > `Generate Password` - 1. Store the password in Keychain - + ```bash xcrun altool \ --store-password-in-keychain-item "METABASE_MAC_APP_BUILD_PASSWORD" \ -u "$METABASE_MAC_APP_BUILD_APPLE_ID" \ -p <secret_password> ``` - + +1) Install Clojure CLI + + ```bash + brew install clojure + ``` + ## Building & Releasing the Mac App - + After following the configuration steps above, to build and release the app you can use the `./bin/osx-release` script: 1. Copy latest uberjar to the Mac App build directory - ```bash - cp path/to/metabase.jar OSX/Resources/metabase.jar - ``` - + ```bash + cp path/to/metabase.jar OSX/Resources/metabase.jar + ``` + 1. Bump version number (`tag`) in `./bin/version.properties` unless uberjar was built locally The build script reads this file, which is generated by `./bin/build`; it assumes the uberjar was built locally, which is a bad assumption. This is something we should fix - -1. Bundle entire app, and upload to s3 - ```bash - ./bin/osx-release - ``` - -## Debugging ./bin/osx-release +1. Bundle entire app, and upload to s3 -* You can run individual steps of the release script by passing in the appropriate step subroutines. e.g. `./bin/osx-release create_dmg upload`. - The entire sequence of different steps can be found at the bottom of `./bin/osx-release`. -* Generating the DMG seems to be somewhat finicky, so if it fails with a message like "Device busy" trying the step again a few times usually resolves the issue. - You can continue the build process from the DMG creation step by running `./bin/osx-release create_dmg upload`. + ```bash + cd OSX + clojure -m macos-release + ``` diff --git a/frontend/src/metabase/meta/Dashboard.js b/frontend/src/metabase/meta/Dashboard.js index e18e51183c2f6867f03ff211a0cdded7c2ae887f..a401defd6b5e53da7f59f7602792eca7e4278041 100644 --- a/frontend/src/metabase/meta/Dashboard.js +++ b/frontend/src/metabase/meta/Dashboard.js @@ -206,6 +206,10 @@ export function getParameterMappingOptions( card: Card, ): ParameterMappingUIOption[] { const options = []; + if (card.display === "text") { + // text cards don't have parameters + return []; + } const query = new Question(card, metadata).query(); diff --git a/frontend/src/metabase/meta/types/Parameter.js b/frontend/src/metabase/meta/types/Parameter.js index c4f695d1ecfe749cd0214902bcf60240c8799c61..9024c58969e4881e83666379dca435ae136c4447 100644 --- a/frontend/src/metabase/meta/types/Parameter.js +++ b/frontend/src/metabase/meta/types/Parameter.js @@ -1,6 +1,7 @@ /* @flow */ import type { CardId } from "./Card"; +import type { FieldId } from "./Field"; import type { LocalFieldReference, ForeignFieldReference } from "./Query"; export type ParameterId = string; @@ -18,6 +19,7 @@ export type Parameter = { type: ParameterType, slug: string, default?: string, + field_ids?: FieldId[], target?: ParameterTarget, }; diff --git a/frontend/src/metabase/parameters/components/Parameters.jsx b/frontend/src/metabase/parameters/components/Parameters.jsx index e20e78a6845c1a4dfa3991fe44efb0b3d8d02407..347e7952fb12cefedd9398b92fa2fc35d5e6e461 100644 --- a/frontend/src/metabase/parameters/components/Parameters.jsx +++ b/frontend/src/metabase/parameters/components/Parameters.jsx @@ -1,11 +1,14 @@ /* @flow */ import React, { Component } from "react"; +import { connect } from "react-redux"; import StaticParameterWidget from "./ParameterWidget"; import Icon from "metabase/components/Icon"; import { color } from "metabase/lib/colors"; +import { getMetadata } from "metabase/selectors/metadata"; + import querystring from "querystring"; import cx from "classnames"; @@ -14,9 +17,12 @@ import type { ParameterId, Parameter, ParameterValues, + ParameterValueOrArray, } from "metabase/meta/types/Parameter"; import type { DashboardWithCards } from "metabase/meta/types/Dashboard"; +import type Field from "metabase-lib/lib/metadata/Field"; +import type Metadata from "metabase-lib/lib/metadata/Metadata"; type Props = { className?: string, @@ -33,6 +39,7 @@ type Props = { vertical?: boolean, commitImmediately?: boolean, + metadata?: Metadata, query?: QueryParams, setParameterName?: (parameterId: ParameterId, name: string) => void, @@ -46,6 +53,7 @@ type Props = { setEditingParameter?: (parameterId: ParameterId) => void, }; +@connect(state => ({ metadata: getMetadata(state) })) export default class Parameters extends Component { props: Props; @@ -57,13 +65,17 @@ export default class Parameters extends Component { componentWillMount() { // sync parameters from URL query string - const { parameters, setParameterValue, query } = this.props; + const { parameters, setParameterValue, query, metadata } = this.props; if (setParameterValue) { for (const parameter of parameters) { - if (query && query[parameter.slug] != null) { - setParameterValue(parameter.id, query[parameter.slug]); - } else if (parameter.default != null) { - setParameterValue(parameter.id, parameter.default); + const queryParam = query && query[parameter.slug]; + if (queryParam != null || parameter.default != null) { + const value = queryParam != null ? queryParam : parameter.default; + const fieldIds = parameter.field_ids || []; + // $FlowFixMe + const fields = fieldIds.map(id => metadata.field(id)); + // $FlowFixMe + setParameterValue(parameter.id, parseQueryParam(value, fields)); } } } @@ -231,3 +243,22 @@ const SortableParameterWidget = SortableElement(StaticParameterWidget); const SortableParameterWidgetList = SortableContainer( StaticParameterWidgetList, ); + +export function parseQueryParam( + value: ParameterValueOrArray, + fields: Field[], +): any { + if (Array.isArray(value)) { + return value.map(v => parseQueryParam(v, fields)); + } + // [].every is always true, so only check if there are some fields + if (fields.length > 0) { + if (fields.every(f => f.isNumeric())) { + return parseFloat(value); + } + if (fields.every(f => f.isBoolean())) { + return value === "true" ? true : value === "false" ? false : value; + } + } + return value; +} diff --git a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx index e6e2f2aef18e43b85823b12c9479157ebb75ca9a..3ac30089836c8bc2cf0937eb4c33c3cc192bd2ab 100644 --- a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx +++ b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx @@ -272,13 +272,23 @@ export default class LineAreaBarChart extends Component { const settings = this.getSettings(); - let multiseriesHeaderSeries; - if (series.length > 1 || onAddSeries || onEditSeries || onRemoveSeries) { - multiseriesHeaderSeries = series; - } + const hasMultiSeriesHeaderSeries = !!( + series.length > 1 || + onAddSeries || + onEditSeries || + onRemoveSeries + ); const hasTitle = showTitle && settings["card.title"]; + const defaultSeries = [ + { + card: { + name: " ", + }, + }, + ]; + return ( <div className={cx( @@ -295,10 +305,10 @@ export default class LineAreaBarChart extends Component { actionButtons={actionButtons} /> )} - {multiseriesHeaderSeries || (!hasTitle && actionButtons) ? ( // always show action buttons if we have them + {hasMultiSeriesHeaderSeries || (!hasTitle && actionButtons) ? ( // always show action buttons if we have them <LegendHeader className="flex-no-shrink" - series={multiseriesHeaderSeries} + series={hasMultiSeriesHeaderSeries ? series : defaultSeries} settings={settings} hovered={hovered} onHoverChange={this.props.onHoverChange} @@ -335,7 +345,7 @@ function transformSingleSeries(s, series, seriesIndex) { const { card, data } = s; // HACK: prevents cards from being transformed too many times - if (card._transformed) { + if (data._transformed) { return [s]; } @@ -396,7 +406,6 @@ function transformSingleSeries(s, series, seriesIndex) { ] .filter(n => n) .join(": "), - _transformed: true, _breakoutValue: breakoutValue, _breakoutColumn: cols[seriesColumnIndex], }, @@ -404,6 +413,7 @@ function transformSingleSeries(s, series, seriesIndex) { rows: breakoutRowsByValue.get(breakoutValue), cols: rowColumnIndexes.map(i => cols[i]), _rawCols: cols, + _transformed: true, }, // for when the legend header for the breakout is clicked clicked: { @@ -439,7 +449,6 @@ function transformSingleSeries(s, series, seriesIndex) { card: { ...card, name: name, - _transformed: true, _seriesIndex: seriesIndex, // use underlying column name as the seriesKey since it should be unique // EXCEPT for dashboard multiseries, so check seriesIndex == 0 @@ -453,6 +462,7 @@ function transformSingleSeries(s, series, seriesIndex) { return newRow; }), cols: rowColumnIndexes.map(i => cols[i]), + _transformed: true, _rawCols: cols, }, }; diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index a1df1c16ed09b6321ead98099c5b3d19a8ccf779..7e1936f7e79e7af7f84f702489887a9b372d324d 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -463,7 +463,14 @@ export default class Visualization extends React.PureComponent { ); let { gridSize, gridUnit, classNameWidgets } = this.props; - if (!gridSize && gridUnit) { + if ( + !gridSize && + gridUnit && + // Check that width/height are set. If they're not, we want to pass + // undefined rather than {width: 0, height: 0}. Passing 0 will hide axes. + width != null && + height != null + ) { gridSize = { width: Math.round(width / (gridUnit * 4)), height: Math.round(height / (gridUnit * 3)), diff --git a/frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx b/frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx index d52f318f305ef6aece4da87ffcb74847b160af97..a2fbd5e9b6c674134dd05fa529bcff8f5bd16810 100644 --- a/frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx +++ b/frontend/src/metabase/visualizations/visualizations/ObjectDetail.jsx @@ -19,6 +19,7 @@ import { singularize, inflect } from "inflection"; import { formatValue, formatColumn } from "metabase/lib/formatting"; import { isQueryable } from "metabase/lib/table"; +import Tables from "metabase/entities/tables"; import { loadObjectDetailFKReferences, followForeignKey, @@ -60,6 +61,7 @@ type Props = VisualizationProps & { tableForeignKeys: ?(ForeignKey[]), tableForeignKeyReferences: { [id: ForeignKeyId]: ForeignKeyCountInfo }, loadObjectDetailFKReferences: () => void, + fetchTableFks: (id: any) => void, followForeignKey: (fk: any) => void, viewNextObjectDetail: () => void, viewPreviousObjectDetail: () => void, @@ -73,6 +75,7 @@ const mapStateToProps = state => ({ // ugh, using function form of mapDispatchToProps here due to circlular dependency with actions const mapDispatchToProps = dispatch => ({ + fetchTableFks: id => dispatch(Tables.objectActions.fetchForeignKeys({ id })), loadObjectDetailFKReferences: (...args) => dispatch(loadObjectDetailFKReferences(...args)), followForeignKey: (...args) => dispatch(followForeignKey(...args)), @@ -96,6 +99,10 @@ export class ObjectDetail extends Component { }; componentDidMount() { + const { tableMetadata } = this.props; + if (tableMetadata && tableMetadata.fks == null) { + this.props.fetchTableFks(tableMetadata.id); + } // load up FK references if (this.props.tableForeignKeys) { this.props.loadObjectDetailFKReferences(); diff --git a/frontend/test/metabase/dashboard/dashboard.cy.spec.js b/frontend/test/metabase/dashboard/dashboard.cy.spec.js index afc808578718169225492542cbc27f55353a4fa8..37231e7c3743cc3b9ec3f8da10d2aac40b73df03 100644 --- a/frontend/test/metabase/dashboard/dashboard.cy.spec.js +++ b/frontend/test/metabase/dashboard/dashboard.cy.spec.js @@ -1,4 +1,4 @@ -import { signInAsAdmin, restore } from "__support__/cypress"; +import { signInAsAdmin, restore, popover, modal } from "__support__/cypress"; describe("dashboard", () => { before(restore); @@ -82,4 +82,40 @@ var iframeUrl = METABASE_SITE_URL + "/embed/dashboard/" + token + "#bordered=tru .clear(); cy.contains("Save").click(); }); + + it("should let you add a parameter to a dashboard with a text box", () => { + cy.visit("/dashboard/1"); + // click pencil icon to edit + cy.get(".Icon-pencil").click(); + // add text box with text + cy.get(".Icon-string").click(); + cy.get(".DashCard") + .last() + .find("textarea") + .type("text text text"); + cy.get(".Icon-funnel_add").click(); + popover() + .contains("Other Categories") + .click(); + cy.contains("Done").click(); + cy.contains("Save").click(); + + // confirm text box and filter are still there + cy.contains("text text text"); + cy.get("input[placeholder=Category]"); + + // reset + // remove text box + cy.get(".Icon-pencil").click(); + cy.get(".DashCard") + .last() + .find(".Icon-close") + .click({ force: true }); + modal() + .contains("button", "Remove") + .click({ force: true }); + // remove filter + cy.contains("Remove").click(); + cy.contains("Save").click(); + }); }); diff --git a/frontend/test/metabase/parameters/components/Parameters.unit.spec.js b/frontend/test/metabase/parameters/components/Parameters.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f3fd1f41ebbbcbd85a198f4a1fe3e95e0b756c6e --- /dev/null +++ b/frontend/test/metabase/parameters/components/Parameters.unit.spec.js @@ -0,0 +1,28 @@ +import { ORDERS, PRODUCTS } from "__support__/sample_dataset_fixture"; + +import { parseQueryParam } from "metabase/parameters/components/Parameters"; + +describe("Parameters", () => { + describe("parseQueryParam", () => { + it("should parse numbers", () => { + expect(parseQueryParam("1.23", [ORDERS.TOTAL])).toBe(1.23); + }); + it("should parse booleans", () => { + // the sample dataset doesn't have any boolean columns, so we fake one + const field = { isBoolean: () => true, isNumeric: () => false }; + expect(parseQueryParam("true", [field])).toBe(true); + }); + it("should parse multiple values", () => { + const result = parseQueryParam(["123", "321"], [ORDERS.PRODUCT_ID]); + expect(result).toEqual([123, 321]); + }); + it("should not parse if some connected fields are strings", () => { + const result = parseQueryParam("123", [PRODUCTS.ID, PRODUCTS.TITLE]); + expect(result).toBe("123"); + }); + it("should not parse if there are no fields", () => { + const result = parseQueryParam("123", []); + expect(result).toBe("123"); + }); + }); +}); diff --git a/frontend/test/metabase/query_builder/components/DataSelector.unit.spec.js b/frontend/test/metabase/query_builder/components/DataSelector.unit.spec.js index e6150d7502f82011e08906a76da5ea0d1df2c358..31d4889117b2b49886edfcd5389b095bdbb09703 100644 --- a/frontend/test/metabase/query_builder/components/DataSelector.unit.spec.js +++ b/frontend/test/metabase/query_builder/components/DataSelector.unit.spec.js @@ -21,7 +21,7 @@ describe("DataSelector", () => { let originalTimeout; beforeEach(() => { originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; - jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; }); afterEach(() => { diff --git a/frontend/test/metabase/scenarios/chart_drill.cy.spec.js b/frontend/test/metabase/scenarios/chart_drill.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f67b182514bdc655033d45896355dae41f9b4c55 --- /dev/null +++ b/frontend/test/metabase/scenarios/chart_drill.cy.spec.js @@ -0,0 +1,52 @@ +import { signInAsAdmin } from "__support__/cypress"; + +describe("chart drill", () => { + beforeEach(signInAsAdmin); + + it("should allow brush date filter", () => { + cy.visit("/question/new"); + cy.contains("Simple question").click(); + cy.contains("Sample Dataset").click(); + cy.contains("Orders").click(); + cy.contains("37.65"); + + // count by month created and product category + cy.contains("Summarize").click(); + cy.contains("Summarize by") + .parent() + .parent() + .as("summarizeSidebar"); + + cy.get("@summarizeSidebar") + .contains("Created At") + .click(); + cy.get("@summarizeSidebar") + .contains("Category") + .parent() + .find(".Icon-add") + .click({ force: true }); + + cy.contains("Done").click(); + + // wait for chart to expand and display legend/labels + cy.contains("Gadget"); + cy.contains("January, 2017"); + cy.wait(500); // wait longer to avoid grabbing the svg before a chart redraw + + // drag across to filter + cy.get(".dc-chart svg") + .trigger("mousedown", 100, 200) + .trigger("mousemove", 200, 200) + .trigger("mouseup", 200, 200); + + // new filter applied + cy.contains("Created At between June, 2016 October, 2016"); + // more granular axis labels + cy.contains("June, 2016"); + // confirm that product category is still broken out + cy.contains("Gadget"); + cy.contains("Doohickey"); + cy.contains("Gizmo"); + cy.contains("Widget"); + }); +}); diff --git a/frontend/test/metabase/scenarios/object_detail.cy.spec.js b/frontend/test/metabase/scenarios/object_detail.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..da859604f7cea118e378c795eeafaa7f81cb505f --- /dev/null +++ b/frontend/test/metabase/scenarios/object_detail.cy.spec.js @@ -0,0 +1,19 @@ +import { signInAsNormalUser } from "__support__/cypress"; + +describe("ObjectDetail", () => { + beforeEach(signInAsNormalUser); + + it("should show orders/reviews connected to a product", () => { + cy.visit("/browse/1"); + cy.contains("Products").click(); + // click on product #1's id + cy.contains(/^1$/).click(); + // check that the correct counts of related tables appear + cy.contains("Orders") + .parent() + .contains("93"); + cy.contains("Reviews") + .parent() + .contains("8"); + }); +}); diff --git a/frontend/test/metabase/visualizations/components/Visualization.e2e.spec.js b/frontend/test/metabase/visualizations/components/Visualization.e2e.spec.js index 2d543cd8b36285415929affef9ce1d67d778af1c..1b2036109a152a567e1cf8cea86456700c1f46b1 100644 --- a/frontend/test/metabase/visualizations/components/Visualization.e2e.spec.js +++ b/frontend/test/metabase/visualizations/components/Visualization.e2e.spec.js @@ -114,6 +114,13 @@ describe("Visualization", () => { }); expect(getTitles(viz)).toEqual([["Foo_name"]]); }); + it("should render a blank title", () => { + const viz = renderVisualization({ + rawSeries: [LineCard("")], + showTitle: true, + }); + expect(getTitles(viz)).toEqual([["_name"]]); + }); it("should render normal title and breakout multiseries titles", () => { const viz = renderVisualization({ rawSeries: [MultiseriesLineCard("Foo")], diff --git a/test/metabase/middleware/json_test.clj b/test/metabase/middleware/json_test.clj index d3c3f1e65da658a0bc1011ca69a3ac6e234ec0d1..1393c1af8f0d4b1a07a1ace38404cead462a1495 100644 --- a/test/metabase/middleware/json_test.clj +++ b/test/metabase/middleware/json_test.clj @@ -12,16 +12,3 @@ "{\"my-bytes\":\"0xC42360D7\"}" (json/generate-string {:my-bytes (byte-array [196 35 96 215 8 106 108 248 183 215 244 143 17 160 53 186 213 30 116 25 87 31 123 172 207 108 47 107 191 215 76 92])})) - -;; Make sure we send you an informative error message if you try to send an API request without Content-Type: -;; application/json headers -#_(expect - {:body "Metabase only supports JSON requests. Make sure you set a Content-Type: application/json header." - :status 400} - (try - (http/post - (str mb-http/*url-prefix* (format "/user/%d/qbnewb" (test-users/user->id :crowberto))) - {:headers {"X-Metabase-Session" (mb-http/authenticate (test-users/user->credentials :crowberto))}}) - (catch ExceptionInfo e - (let [response (:object (ex-data e))] - (select-keys response [:body :status])))))