Skip to content

Commit

Permalink
Merge pull request #16 from pitch-io/preloads-simplification
Browse files Browse the repository at this point in the history
Support a setup file namespace
  • Loading branch information
jo-sm authored Feb 22, 2023
2 parents caecd11 + cedfa1b commit 218b261
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 36 deletions.
11 changes: 2 additions & 9 deletions cljest/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
const path = require("path");
const fs = require("fs");
const config = require("./jest.config");
const { loadSetupFile } = require("jest-preset-cljest/utils");

const preloadFile = path.resolve(".jest/cljest.preloads.js");

// The preloads files may not exist if the initial compilation failed
if (fs.existsSync(preloadFile)) {
require(preloadFile);
}
loadSetupFile();
3 changes: 0 additions & 3 deletions cljest/src/cljest/compilation.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
[cljest.compilation.server :as server]
[cljest.compilation.shadow :as shadow]))

;; TODO:
;; - Expose a preloads check endpoint, to check if the preloads NS is ready or not

(defn ^:private setup!
"Performs setup that is shared between watch and compile mode.
Expand Down
2 changes: 1 addition & 1 deletion cljest/src/cljest/compilation/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
[:test-src-dirs [:sequential :string]]
[:ns-suffixes [:sequential {:default ['-test]} :symbol]]
[:mode [:enum {:error/message "only :all is allowed" :default :all} :all]]
[:preloads-ns [:symbol {:default 'cljest.preloads}]]])
[:setup-ns [:symbol {:default 'cljest.setup}]]])

(defn ^:private read-edn-safely
"Given a File instance, reads it and attempts to parse as EDN. If it fails, returns nil rather than throwing."
Expand Down
54 changes: 52 additions & 2 deletions cljest/src/cljest/compilation/server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
[cljest.compilation.fs :as fs]
[cljest.compilation.shadow :as shadow]
[clojure.core.async :as as]
[clojure.string :as str]
[ring.middleware.defaults :refer [site-defaults wrap-defaults]]
[ring.middleware.params :refer [wrap-params]]
[ring.middleware.resource :refer [wrap-resource]]
[shadow.cljs.util]
[taoensso.timbre :as log]))

;; Jetty announces some debug information when it's imported, so to avoid this we require after telling it not
Expand All @@ -30,8 +32,8 @@
!build-status
(atom {:status :unknown}))

(defn- compile-and-update-build-status!
"Compiles ::jest and updates the `!build-status` atom with the latest state
(defn ^:private compile-and-update-build-status!
"Compiles `::jest` and updates the `!build-status` atom with the latest state
from the server. Returns the updated state."
[]

Expand Down Expand Up @@ -65,6 +67,50 @@

@!build-status))

(defn ^:private ns->built-ns-path
[ns]
(let [build-dir (shadow/get-build-directory)
relative-path (munge (name ns))]
(str build-dir "/" relative-path ".js")))

(defn ^:private json-response
([body]
(json-response 200 body))

([status body]
{:status status
:headers {"Content-Type" "application/json"}
:body (cheshire/generate-string body)}))

(defn ^:private ns-successfully-compiled?
[ns]
(let [{:keys [status error]} @!build-status]
(case status
:unknown false
:initial-failure false
:success true
:failure (str/includes? error (name ns)))))

(defn ^:private handle-setup-file
"By the time /setup-file is called, compilation should be done, so we can trust the build status."
[]
(let [{:keys [setup-ns]} (config/get-config!)]
(cond
(= :success (:status @!build-status))
(json-response {:path (ns->built-ns-path setup-ns)})

(= :initial-failure (:status @!build-status))
(json-response 422 @!build-status)

(and (= :failure (:status @!build-status)) (ns-successfully-compiled? setup-ns))
(json-response {:path (ns->built-ns-path setup-ns)})

(and (= :failure (:status @!build-status)) (not (ns-successfully-compiled? setup-ns)))
(json-response 422 @!build-status)

:else
(json-response 500 {:error "Compilation is in an unknown state"}))))

(defn ^:private handle-compile
[]
(let [{:keys [status]} (compile-and-update-build-status!)]
Expand All @@ -90,10 +136,14 @@
(and (= :get request-method) (= "/build-status" uri))
(handle-build-status)

(and (= :get request-method) (= "/setup-file" uri))
(handle-setup-file)

:else
{:status 404}))

(defn start-server!
"Starts the Jest compilation server on the port in cljest.edn."
[]
(shadow/start-server!)
(fs/setup-watchers!)
Expand Down
55 changes: 35 additions & 20 deletions cljest/src/cljest/compilation/shadow.clj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[cljest.compilation.fs :as fs]
[cljest.compilation.utils :as utils]
[clojure.core.async :as as]
[clojure.java.io :as io]
[clojure.set :as set]
[shadow.build :as build]
[shadow.build.classpath]
Expand Down Expand Up @@ -30,38 +31,40 @@
(atom (merge devtools.config/default-config {:fs-watch {:loop-wait Integer/MAX_VALUE}})))

(defn install-config!
"Alters `shadow.cljs.devtools.config/load-cljs-edn` to use our internal config atom rather than a file."
"Alters `shadow.cljs.devtools.config/load-cljs-edn` to use our internal config atom rather than a file.
Some functions either do not allow passing the config or just call `load-cljs-edn`, which means we have no choice but to
forcibly override the function to return the latest value of our internal config atom."
[]
;; Some functions either do not allow passing the config or just call `load-cljs-edn`, which means we have no choice but to
;; forcibly override the function to return the latest value of our internal config atom.
(alter-var-root (var devtools.config/load-cljs-edn) (fn [& _] (fn [& _] @shadow-config))))

(defn ^:private get-runtime-instance!
[]
(devtools.server.runtime/get-instance!))

(defn start-server!
"Starts the shadow dev server, and stops the automatically set up file watchers."
[]
(devtools.server/start! @shadow-config)
(devtools.server.fs-watch/stop (:cljs-watch (get-runtime-instance!))))

(defn- get-system-bus
(defn ^:private get-system-bus
"Gets the system bus from the runtime instance. Used for pub/sub events that happen during the lifecycle of
the server."
[]
(get @devtools.server.runtime/instance-ref :system-bus))

(defn- sub-to-system-bus-topic
(defn ^:private sub-to-system-bus-topic
"Subscribe to `topic` from the system bus using `chan`."
[chan topic]
(devtools.server.system-bus/sub (get-system-bus) topic chan))

(defn- unsub-to-system-bus-topic
(defn ^:private unsub-to-system-bus-topic
"Unsubscribe to `topic` from the system bus using `chan`."
[chan topic]
(devtools.server.system-bus/unsub (get-system-bus) topic chan))

(defn- get-1st-msg-on-chan
(defn ^:private get-1st-msg-on-chan
"Subscribes to `topic` using `chan`, gets the first message that the channel listens for,
and then unsubscribes, returning message. If `timeout-ms` is provided will wait up to that
number of ms for a message or return `nil`.
Expand All @@ -81,12 +84,12 @@

msg)))

(defn- get-build-worker
(defn ^:private get-build-worker
[]
(let [{:keys [supervisor]} (devtools.server.runtime/get-instance!)]
(devtools.server.supervisor/get-worker supervisor build-target)))

(defn- sync-worker!
(defn ^:private sync-worker!
[]
(devtools.server.worker/sync! (get-build-worker)))

Expand All @@ -101,7 +104,8 @@

result))

(defn update-build-entries!
(defn ^:private update-build-entries!
"Called when the shadow config is updated to update the worker's build entries."
[{:keys [proc-control]}]
(let [reply-chan (as/chan)]
(as/>!! proc-control {:type :update-build-entries :reply-to reply-chan})
Expand Down Expand Up @@ -150,14 +154,15 @@

(sync-worker!)))

(defn- publish-file-changes!
"For all directories being watched by the runtime instance, publish any changes in those dirs. If there are changes,
waits for all processing done as a result of the change event.
(defn ^:private publish-file-changes!
"For all directories being watched, publish any changes in those dirs. If there are changes, waits for all processing
done as a result of the change event.
This is done in this way, rather than watching normally, primarily because Jest includes a file watcher and we utilize
its watching capabilities to call the API and we don't want to also use shadow's. However, due to the way shadow works,
This is done in this way, rather than watching normally in shadow, primarily because Jest includes a file watcher and
we utilize its watching capabilities to call the API and don't want to use shadow's. However, due to the way shadow works,
we do need to check what files have changed and let the worker(s) know so that when the next compilation happens, the
worker actually attempts to compile the file. Without this the compilation would not be controllable and consistent."
worker actually attempts to compile the right file(s). Without this the compilation would not be controllable and consistent
from the perspective of the Jest process."
[]
;; Inside of `shadow-cljs`, the actual code that handles emitting when something actually changes lives in the function
;; `devtools.server.reload-classpath/process-updates`. This function performs some calculations and, if it detects any
Expand All @@ -181,15 +186,23 @@
(get-1st-msg-on-chan fs-watch-chan ::updates-processed)))))))

(defn get-compilation-result
"Gets the next successful or failure compilation result for `target`."
"Gets the next :build-complete or :build-failure result for compilation of ::jest."
[]
(let [compilation-chan (as/chan 1 (filter #(contains? #{:build-complete :build-failure} (:type %))))]

(get-1st-msg-on-chan compilation-chan [:shadow.cljs.model/worker-output build-target])))

(defn generate-build! []
(defn get-build-directory
"Returns the absolute path of the build directory."
[]
(let [dir (io/file ".jest")]
(.getCanonicalPath dir)))

(defn generate-build!
"Generates an initial build based on the test namespaces, setup namespace, and compiler options."
[]
(let [test-nses (fs/get-test-files-from-src-dirs)
{:keys [preloads-ns compiler-options]} (config/get-config!)
{:keys [setup-ns compiler-options]} (config/get-config!)
build-definition {:build-id build-target
:target :npm-module
:build-options {:greedy true :dynamic-resolve true}
Expand All @@ -200,15 +213,17 @@
compiler-options)
:output-dir ".jest"
:devtools {:enabled false}
:entries (into [] (conj test-nses preloads-ns))}]
:entries (into [] (conj test-nses setup-ns))}]
(swap! shadow-config assoc-in [:builds build-target] build-definition)))

(defn publish-and-compile!
"Publishes and watched directory fs changes and triggers a compilation. Blocks until compilation is complete."
[]
(publish-file-changes!)
(devtools.api/watch-compile! build-target))

(defn compile!
"Triggers a compilation."
[]
(devtools.api/compile build-target))

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
(ns cljest.preloads
(ns cljest.setup
"The default setup file for cljest configurations in Jest."
(:require [applied-science.js-interop :as j]
lambdaisland.deep-diff2))

Expand Down
67 changes: 67 additions & 0 deletions jest-preset-cljest/utils-load-setup-file-process.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const { runAsWorker } = require("synckit");
const fetch = require("node-fetch");
const nodePath = require("path");

/**
* Replaces hyphens with underscores for the string.
*
* This won't work for a more complicated namespace, but let's cross that bridge
* when we get there.
*
* @param {String} str
*/
function kindOfMunge(str) {
return str.replace(/\-/g, "_");
}

function ciGetSetupFile(config, buildDir) {
const { "setup-ns": raw } = config;

let setupFilePath;
if (raw) {
const { sym: ns } = raw;
setupFilePath = `${kindOfMunge(ns)}.js`;
} else {
setupFilePath = `cljest.setup.js`;
}

return {
status: "success",
path: nodePath.resolve(buildDir, setupFilePath),
};
}

async function serverGetSetupFile(serverUrl) {
const resp = await fetch(`${serverUrl}/setup-file`);

const { error, path } = await resp.json();

if (resp.ok) {
return {
status: "success",
path,
};
}

return {
status: "failure",
error,
};
}

runAsWorker(async function loadSetupFileFn(buildDir, serverUrl, cljestConfig) {
try {
if (process.env.CI) {
return ciGetSetupFile(cljestConfig, buildDir);
} else {
return await serverGetSetupFile(serverUrl);
}
} catch (e) {
// Always capture any error that happens with the code above and return it in the expected object shape
// Otherwise, there be dragons.
return {
status: "failure",
error: e.message,
};
}
});
29 changes: 29 additions & 0 deletions jest-preset-cljest/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { createSyncFn } = require("synckit");
const { parseEDNString } = require("edn-data");

const jestProjectDir = process.cwd();
Expand Down Expand Up @@ -74,6 +75,33 @@ function generateTestRegexes() {
return nsSuffixes.map((suffix) => `(.*)${suffix}.cljs`);
}

/**
* Loads the setup file defined in the cljest config, either from the server, or from the compiled build
* directory (depending on if Jest is running with the CI env var).
*
* Will throw if the file could not be loaded for some reason.
*/
function loadSetupFile() {
const callProcess = createSyncFn(
path.resolve(__dirname, "utils-load-setup-file-process.js")
);
const cljestConfig = getCljestConfig();
const buildDir = getBuildDir();
const serverUrl = getServerUrl();

const {
status,
error,
path: setupFilePath,
} = callProcess(buildDir, serverUrl, cljestConfig);

if (status === "success") {
return require(setupFilePath);
}

throw new Error(error);
}

module.exports = {
getRootDir,
getBuildDir,
Expand All @@ -82,4 +110,5 @@ module.exports = {
getCljestConfig,
getPathsFromCljestConfig,
generateTestRegexes,
loadSetupFile,
};

0 comments on commit 218b261

Please sign in to comment.