Miguel de Benito Delgado 13af3fb323 🚧 WIP 27, improve logging and console reporting.
AI-assisted-by: deepseek-flash, gpt-6.1-sol
2026-10-29 13:58:32 +01:00

305 lines
13 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns benches.render-wasm.node.runner
"CLI parsing, offline comparison and deferred execution commands."
(:require
["path" :as path]
[app.common.transit :as transit]
[benches.render-wasm.feature-plan :as feature-plan]
[benches.render-wasm.node.compare :as compare]
[benches.render-wasm.node.log :as log]
[benches.render-wasm.node.options :as options]
[clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]]))
(def ^:private max-timeout-ms 2147483647)
(def ^:private positive-timeout
[(fn [n] (and (js/Number.isSafeInteger n)
(pos? n)
(<= n max-timeout-ms)))
"Must be a positive integer up to 2147483647"])
(def ^:private nonnegative-integer
[(fn [n] (and (js/Number.isSafeInteger n) (not (neg? n))))
"Must be a nonnegative integer"])
(def ^:private positive-integer
[(fn [n] (and (js/Number.isSafeInteger n) (pos? n)))
"Must be a positive integer"])
(def ^:private uint32
[(fn [n] (and (js/Number.isSafeInteger n) (<= 0 n 4294967295)))
"Must be a uint32 integer"])
(def ^:private nonempty
[(fn [s] (not (str/blank? s))) "Must be a nonempty value"])
(defn- parse-number
"Parses a numeric CLI value. Blank input stays invalid instead of becoming zero."
[text]
(if (str/blank? text) js/NaN (js/Number text)))
(def cli-options
[["-h" "--help" "Print this help"]
["-q" "--quiet" "Only warnings, errors and results"]
["-v" "--verbose" "Show debug details and raw build output"]
[nil "--[no-]color" "Colorize terminal output; default auto" :default nil]
[nil "--[no-]default-features" "Enable Cargo defaults" :default true]
[nil "--features FEATURES" "Comma-separated functional Cargo features"
:parse-fn options/parse-feature-list]
[nil "--max-features N" "ab feature limit (1..6)"
:default feature-plan/default-max-features
:parse-fn parse-number
:validate [#(and (js/Number.isSafeInteger %) (<= 1 % 6)) "must be an integer from 1 to 6"]]
[nil "--confirm" "Confirm ab with >8 configurations or an empty filter" :default false]
[nil "--dry-run" "Validate and print the ab plan without executing" :default false]
[nil "--build ID" "Reuse a ready build" :id :build-id
:validate nonempty]
[nil "--filter SUBSTR" "Select cases whose id contains SUBSTR"]
[nil "--seed N" "Master scene seed" :default 42
:parse-fn parse-number :validate uint32]
[nil "--warmups N" "Warmup attempts per case" :default 3
:parse-fn parse-number :validate nonnegative-integer]
[nil "--repetitions N" "Measured attempts per case" :default 10
:parse-fn parse-number :validate positive-integer]
[nil "--timeout-ms MS" "Attempt deadline" :default 30000
:parse-fn parse-number :validate positive-timeout]
[nil "--build-timeout-ms MS" "Complete preparation deadline"
:default options/default-build-timeout-ms
:parse-fn parse-number :validate positive-timeout]
[nil "--startup-timeout-ms MS" "Host/browser readiness deadline"
:default options/default-startup-timeout-ms
:parse-fn parse-number :validate positive-timeout]
[nil "--output-dir DIR" "Explicit run home; must not exist"
:validate nonempty]
[nil "--remove" "Remove the used build after shutdown"]
[nil "--screenshots" "Capture one screenshot per case"]
[nil "--only-builds" "Purge build attempts"]
[nil "--only-runs" "Purge managed runs"]
[nil "--only-comparisons" "Purge managed feature comparisons"]
[nil "--cases A,B" "Select one baseline,candidate pair"]
[nil "--allow-build-differences" "Allow recorded build differences"]
[nil "--allow-scene-differences" "Allow scene inputs and fingerprint content differences"]
[nil "--allow-all-differences" "Allow all comparison compatibility differences"]])
(defn usage
[summary]
(str "Usage: runner build [options]\n"
" runner purge [--only-builds | --only-runs | --only-comparisons]\n"
" runner run [options]\n"
" runner ab --features A,B [--filter SUBSTR] [--max-features N]\n"
" [--confirm] [--dry-run] [run and comparison options]\n"
" runner compare BASELINE CANDIDATE [--cases A,B]\n"
" [--allow-build-differences] [--allow-scene-differences]\n"
" [--allow-all-differences]\n\n"
"build prints Build <id> and Output <absolute path> on stdout. Exit 0 means\n"
"ready; errors retain the build.transit and build.log for that ID.\n"
"run prints Run <id>, Output <absolute path> and Report on stdout.\n"
"Progress and diagnostics go to stderr. -q/--quiet hides progress;\n"
"-v/--verbose includes raw build output. --[no-]color overrides terminal\n"
"color detection. File logs receive all levels without logger colors.\n"
"Builds and managed runs live under target/performance/render-wasm.\n"
"compare reads saved schema:run records and starts no build, host or browser.\n"
"Build and scene permissions combine; operation and runtime checks stay strict.\n"
"All-differences permits every compatibility difference. Reports retain them.\n\n"
"ab uses every on/off configuration with Cargo defaults disabled; declared\n"
"defaults must be empty. --max-features defaults to 3 (range 1..6).\n"
"Use --confirm above eight configurations or with an empty filter.\n"
"--dry-run checks metadata, cases and output freshness without confirmation,\n"
"output creation, compilation or browser startup. ab accepts run budgets,\n"
"timeouts, seed, filter, output and screenshots, plus comparison permissions.\n"
"It rejects --build, --remove, --cases and --[no-]default-features.\n"
"ab prints Comparison <id>, Output <path> and Status <status> on stdout;\n"
"feature-comparison.log records comparison progress. Diagnostics and\n"
"failures go to stderr. --dry-run prints only the plan without creating\n"
"the comparison home. Managed output lives under\n"
"feature-comparisons/<ab-id>/feature-comparison.transit.\n\n"
"Preparation and runs require an idle checkout with dependencies installed.\n"
"Keep renderer watchers stopped until preparation ends. purge assumes no\n"
"experiment is using the managed paths.\n\n"
summary))
(def ^:private value-flags
"Long options that consume the next argument when no value follows an equals sign."
(into #{}
(keep (fn [[_ long-option]]
(when (and long-option (re-find #"\s" long-option))
(first (str/split long-option #"\s+")))))
cli-options))
(defn- supplied-flags
[args]
(loop [tokens (seq args)
flags #{}
value? false
operand? false]
(if-let [token (first tokens)]
(cond
value?
(recur (next tokens) flags false operand?)
(and (not operand?) (= "--" token))
(recur (next tokens) flags false true)
operand?
(recur (next tokens) flags false true)
(str/starts-with? token "--")
(let [[flag inline-value] (str/split token #"=" 2)]
(recur (next tokens)
(conj flags flag)
(and (contains? value-flags flag) (nil? inline-value))
false))
:else
(recur (next tokens) flags false false))
flags)))
(def ^:private build-flags
#{"--default-features" "--no-default-features" "--features" "--build-timeout-ms"})
(def ^:private run-flags
#{"--build" "--filter" "--seed" "--warmups" "--repetitions" "--timeout-ms"
"--startup-timeout-ms" "--output-dir" "--remove" "--screenshots"})
(def ^:private purge-flags
#{"--only-builds" "--only-runs" "--only-comparisons"})
(def ^:private ab-flags
#{"--max-features" "--confirm" "--dry-run"})
(def ^:private compare-flags
#{"--cases" "--allow-build-differences" "--allow-scene-differences"
"--allow-all-differences"})
(defn- invalid-args
[message]
(throw (ex-info message {:type ::invalid-args})))
(defn- some-flags?
[flags group]
(boolean (some flags group)))
(defn- check-command-flags!
[command flags]
(when (and (not= command "ab") (some-flags? flags ab-flags))
(invalid-args "ab planning options require ab"))
(let [build? (some-flags? flags build-flags)
run? (some-flags? flags run-flags)
purge? (some-flags? flags purge-flags)
compare? (some-flags? flags compare-flags)]
(case command
"build"
(do
(when purge? (invalid-args "purge category flags require purge"))
(when run? (invalid-args "run options require run"))
(when compare? (invalid-args "compare options require compare")))
"purge"
(do
(when build? (invalid-args "build options require build"))
(when run? (invalid-args "run options require run"))
(when compare? (invalid-args "compare options require compare")))
"run"
(do
(when purge? (invalid-args "purge category flags require purge"))
(when compare? (invalid-args "compare options require compare")))
"ab"
(do
(when purge? (invalid-args "purge category flags require purge"))
(when (some-flags? flags ["--build" "--remove" "--cases"
"--default-features" "--no-default-features"])
(invalid-args "ab rejects --build, --remove, --cases and --[no-]default-features")))
"compare"
(do
(when purge? (invalid-args "purge category flags require purge"))
(when build? (invalid-args "build options require build"))
(when run? (invalid-args "run options require run"))))))
(defn- check-arguments!
[command arguments]
(let [extra (rest arguments)]
(case command
"compare"
(when-not (= 2 (count extra))
(invalid-args "compare requires BASELINE and CANDIDATE paths"))
(when (seq extra)
(invalid-args (str command " takes no positional arguments"))))))
(defn parse-args
"Parses and validates one runner command without loading a catalog, build,
server, browser or renderer. Options are resolved before any run home."
[args]
(let [args (cond-> args (= "--" (first args)) rest)
{:keys [arguments options errors summary]} (parse-opts args cli-options)
command (first arguments)
flags (supplied-flags args)]
(when (seq errors)
(invalid-args (str/join "\n" errors)))
(when (and (:quiet options) (:verbose options))
(invalid-args "--quiet and --verbose are mutually exclusive"))
(when-not (:help options)
(when-not (and (= 1 (count (take 1 arguments)))
(contains? #{"build" "purge" "run" "ab" "compare"} command))
(invalid-args "expected build, purge, run, ab or compare; try --help"))
(check-command-flags! command flags)
(check-arguments! command arguments)
(when (and (= command "purge")
(> (count (filter options [:only-builds :only-runs :only-comparisons])) 1))
(invalid-args "--only-builds, --only-runs and --only-comparisons are mutually exclusive"))
(when (and (= command "run")
(:build-id options)
(some-flags? flags ["--default-features" "--no-default-features" "--features"]))
(invalid-args "--build cannot be combined with feature-selection flags"))
(when (= command "ab")
(try
(feature-plan/plan (:features options []) options)
(catch :default cause
(invalid-args (ex-message cause))))))
{:command command
:arguments arguments
:options (cond-> options (= command "ab") (assoc :default-features false))
:summary summary}))
(defn ^:async -main
[& args]
(log/configure {})
(try
(let [{:keys [command arguments options summary] :as request} (parse-args args)]
(log/configure {:level (options/console-level options)
:color (options/color-mode options)})
(cond
(:help options)
(log/result (usage summary))
(= command "compare")
(log/result (:text (compare/execute (second arguments) (nth arguments 2)
(select-keys options [:cases :allow-build-differences
:allow-scene-differences
:allow-all-differences]))))
:else
(let [^js commands (js/require (path/join js/__dirname "commands.cjs"))]
(set! (.-exitCode js/process)
(await (.execute commands
(transit/encode-str (select-keys request [:command :options]))))))))
(catch :default cause
(let [data (ex-data cause)]
(log/error {:message (str (or (ex-message cause) (str cause))
"\nTry --help for usage.")
:fields (into (filterv (comp some? second)
[[:type (:type data)] [:baseline (:baseline data)]
[:candidate (:candidate data)]])
(map #(vector :difference (:field %)) (:mismatches data)))
:tail? false}))
(set! (.-exitCode js/process) 1))))