🚧 WIP 25.2 Safer deletion and symlink handling

AI-assisted-by: deepseek-flash, gpt-6.1-sol
This commit is contained in:
Miguel de Benito Delgado 2026-10-27 14:03:34 +01:00
parent c14ca9e508
commit 987fc38e6f
8 changed files with 332 additions and 65 deletions

View File

@ -89,6 +89,12 @@
(recur (path/dirname existing))))
managed))
(defn inside-builds?
"Checks the whole builds tree, resolving symlink ancestors of both paths."
[frontend-root output-path]
(contained? (files/get-real-path (path/join (managed-root frontend-root) "builds"))
(files/get-real-path output-path)))
(defn- build-reference
[root id manifest]
(let [dir (path/join (managed-root root) "builds" id)]
@ -210,31 +216,77 @@
{:type ::not-ready :id id :status (:status manifest)})))
(check-files (assoc build :manifest manifest))))
(def ^:private build-outputs
["build.transit" "build.log" "cases.cjs" "index.html"
"browser/browser.js" "renderer/render-wasm.js" "renderer/render-wasm.wasm"
"renderer/render-wasm.wasm.map" "renderer/shared.js" "renderer/cargo-build.json"
"renderer/worker/render.js"])
(defn- directory?
[file]
(and (fs/existsSync file) (.isDirectory (fs/statSync file))))
(defn- unlink-if-present
[file]
(when-not (directory? file)
(try
(fs/unlinkSync file)
(catch :default error
(when-not (contains? #{"ENOENT" "ENOTDIR"} (.-code error))
(throw error))))))
(defn- clean-build
[dir]
(when (directory? dir)
(doseq [file build-outputs]
(unlink-if-present (path/join dir file)))))
(defn- purge-builds
[root]
(when (directory? root)
(doseq [id (array-seq (fs/readdirSync root))]
(clean-build (path/join root id)))))
(defn- purge-runs
[root]
(loop [pending [root]
visited #{}]
(when-let [dir (peek pending)]
(let [pending (pop pending)
real (when (directory? dir) (fs/realpathSync dir))]
(if (or (nil? real) (contains? visited real))
(recur pending visited)
(do
(unlink-if-present (path/join dir "run.log"))
(let [names (array-seq (fs/readdirSync dir))]
(doseq [file names
:when (str/ends-with? (str/lower-case file) ".png")]
(unlink-if-present (path/join dir file)))
(unlink-if-present (path/join dir "run.transit"))
(recur (into pending (map #(path/join dir %) names))
(conj visited real)))))))))
(defn purge
"Deletes :builds, :runs or both (:all), including unfinished builds.
"Unlinks known outputs from :builds, :runs or both (:all).
The caller must be idle: no build, server or run may still own these paths.
Boot executables and other files beside the managed subroots stay in place.
A selected subroot symlink is removed without deleting its target."
Follows directory symlinks; keeps directories and unrelated files."
[root category]
(let [managed (managed-root root)]
(case category
:builds (fs/rmSync (path/join managed "builds") #js {:recursive true :force true})
:runs (fs/rmSync (path/join managed "runs") #js {:recursive true :force true})
:builds (purge-builds (path/join managed "builds"))
:runs (purge-runs (path/join managed "runs"))
:all (do
(fs/rmSync (path/join managed "builds") #js {:recursive true :force true})
(fs/rmSync (path/join managed "runs") #js {:recursive true :force true}))
(purge-builds (path/join managed "builds"))
(purge-runs (path/join managed "runs")))
(throw (ex-info "invalid renderer artifact category"
{:type ::invalid-category :category category})))))
(defn remove-builds
"Deletes only the selected build IDs. The caller must be idle."
"Unlinks known outputs of selected build IDs. The caller must be idle."
[root ids]
(let [ids (mapv check-id ids)
managed (managed-root root)
builds (path/join managed "builds")]
(when (fs/existsSync builds)
(checked-path (fs/realpathSync managed) builds)
(doseq [id ids]
;; rmSync removes a final symlink itself, not its target.
(fs/rmSync (path/join builds id) #js {:recursive true :force true})))))
(doseq [id ids]
(clean-build (path/join builds id)))))

View File

@ -7,7 +7,16 @@
(ns benches.render-wasm.node.files
"Small filesystem helpers shared by benchmark storage writers."
(:require
["fs" :as fs]))
["fs" :as fs]
["path" :as path]))
(defn get-real-path
"Resolves symlink ancestors even when the final path does not exist yet."
[file]
(let [file (path/resolve file)]
(if (fs/existsSync file)
(fs/realpathSync file)
(path/join (get-real-path (path/dirname file)) (path/basename file)))))
(defn atomic-write
"Writes (UTF-8) content to a temporary file and renames it over `file`,

View File

@ -646,18 +646,14 @@
(dissoc :screenshots :remove)))
(defn ^:async execute
"Executes a prepared selection and owns the run home, host and browser.
"Executes a prepared selection in an allocated home and owns host and browser.
Options are resolved CLI options. root is the frontend checkout. The record
exists before on-created runs, so a browser startup failure still leaves a
valid partial record. Returns {:run RunRecord :home home :exit-code n
:error cause-or-nil}."
[root selected-build cases options signal on-progress on-created]
(let [opts (-> (normalize-options options)
(assoc :run-id (or (:run-id options) (str (random-uuid)))))
home (runs/create-home {:root root
:run-id (:run-id opts)
:output-dir (:output-dir opts)})
[root home selected-build cases options signal on-progress on-created]
(let [opts (assoc (normalize-options options) :run-id (:run-id home))
run (result/create-run
{:run-id (:run-id opts)
:started-at (now)
@ -748,13 +744,13 @@
(defn ^:async run
"Prepares or loads a build, collects matching cases and executes one run.
User output paths resolve before any build work. A build or catalog failure
before the plan exists creates no run record and rethrows with build facts."
The output home is reserved before build work so filesystem errors fail fast.
A build or catalog failure leaves the home without a run record and rethrows
with build facts."
[root options signal on-progress on-created]
(let [opts (normalize-options options)
output-dir (runs/check-output-dir (:output-dir opts))
run-id (str (random-uuid))
opts (assoc opts :run-id run-id :output-dir output-dir)]
(let [opts (normalize-options options)
home (runs/create-home {:root root :run-id (str (random-uuid))
:output-dir (:output-dir opts) :remove (:remove? opts)})]
(try
(let [selected-build
(if-let [id (:build-id opts)]
@ -779,7 +775,7 @@
:build-id (:id selected-build)
:log-path (:log-path selected-build))
cause))))]
(await (execute root selected-build cases opts signal on-progress on-created)))
(await (execute root home selected-build cases opts signal on-progress on-created)))
(catch :default cause
(when on-progress
(on-progress (str "run: " (message-of cause) "\n"))

View File

@ -9,8 +9,8 @@
Runs are saved under <frontend>/target/performance/render-wasm/runs.
This can be overriden in the runner with `--output-dir PATH`. If done, this
path is not included among those purged when cleaning up."
--output-dir selects another destination. Purge cleans files reachable from
the default runs tree, including explicit destinations and directory links."
(:require
["fs" :as fs]
["path" :as path]
@ -37,7 +37,7 @@
(when (str/blank? dir)
(invalid-home "output directory requires a nonempty path" {}))
(let [resolved (path/resolve dir)]
(when (fs/existsSync resolved)
(when (fs/lstatSync resolved #js {:throwIfNoEntry false})
(invalid-home "output directory already exists" {:path resolved}))
resolved)))
@ -45,18 +45,16 @@
"Creates one run home and its empty log. The record is written separately
after the plan exists. An explicit directory must not exist; a managed home
uses the frontend root regardless of cwd."
[{:keys [root run-id output-dir]}]
[{:keys [root run-id output-dir remove]}]
(check-id run-id)
(let [explicit (check-output-dir output-dir)
managed (when-not explicit (artifacts/managed-root root))
runs-dir (when managed (path/join managed "runs"))
dir (or explicit (path/join runs-dir run-id))
(let [dir (check-output-dir
(or output-dir (path/join (artifacts/managed-root root) "runs" run-id)))
record-path (path/join dir "run.transit")
log-path (path/join dir "run.log")]
(when managed
(fs/mkdirSync runs-dir #js {:recursive true}))
(when (and remove (artifacts/inside-builds? root dir))
(invalid-home "--remove cannot use an output directory inside builds" {:path dir}))
(try
(fs/mkdirSync dir)
(fs/mkdirSync dir #js {:recursive true})
(catch :default cause
(invalid-home "cannot create run home"
{:path dir :cause (or (ex-message cause) (str cause))})))

View File

@ -257,38 +257,74 @@
(t/is (= ::artifacts/unsafe-path
(:type (ex-data (failure #(artifacts/load-build @temporary-root (:id build)))))))))
(t/deftest category-purge-deletes-only-the-requested-managed-subroots
(t/deftest category-purge-unlinks-only-known-outputs-and-keeps-directories
(doseq [category [:builds :runs :all]]
(let [root (fs/mkdtempSync (path/join @temporary-root (str (name category) "-")))
building (artifacts/create-build root selection provenance)
building (complete-files (artifacts/create-build root selection provenance))
failed (artifacts/create-build root selection provenance)
managed (artifacts/managed-root root)
run-file (path/join managed "runs" "saved-run" "record.transit")
run-dir (path/join managed "runs" "group" "saved-run")
outputs ["build.transit" "build.log" "cases.cjs" "index.html"
"browser/browser.js" "renderer/render-wasm.js"
"renderer/render-wasm.wasm" "renderer/render-wasm.wasm.map"
"renderer/shared.js" "renderer/cargo-build.json"
"renderer/worker/render.js"]
boot (path/join managed "runner.cjs")
outside (path/join root "unmanaged.txt")]
outside (path/join root "explicit-output" "run.transit")]
(artifacts/write-manifest failed (assoc (:manifest failed) :status :failed))
(write-file run-file "retained run")
(doseq [file outputs]
(write-file (path/join (:dir building) file) "build output"))
(doseq [file ["run.log" "run.transit" "shot.png" "SHOT.PNG"]]
(write-file (path/join run-dir file) "run output"))
(doseq [dir ["run.log" "run.transit" "directory.PNG"]]
(write-file (path/join managed "runs" dir "keep.txt") "keep directory"))
(write-file (path/join managed "runs" "no-marker" "only.PNG") "screenshot")
(write-file (path/join run-dir "record.transit") "unrelated run file")
(write-file (path/join (:dir building) "custom" "build.log") "unrelated build file")
(write-file boot "boot executable")
(write-file outside "unmanaged")
(artifacts/purge root category)
(t/is (= (= category :runs) (fs/existsSync (:dir building))))
(t/is (= (= category :runs) (fs/existsSync (:dir failed))))
(t/is (= (= category :builds) (fs/existsSync run-file)))
(doseq [dir [(:dir building) (:dir failed) (:browser-dir building)
(:renderer-dir building) run-dir]]
(t/is (.isDirectory (fs/statSync dir))))
(doseq [file outputs]
(t/is (= (= category :runs) (fs/existsSync (path/join (:dir building) file)))))
(t/is (= (= category :runs) (fs/existsSync (:manifest-path failed))))
(doseq [file ["run.log" "run.transit" "shot.png" "SHOT.PNG"]]
(t/is (= (= category :builds) (fs/existsSync (path/join run-dir file)))))
(t/is (= (= category :builds)
(fs/existsSync (path/join managed "runs" "no-marker" "only.PNG"))))
(doseq [dir ["run.log" "run.transit" "directory.PNG"]]
(t/is (fs/existsSync (path/join managed "runs" dir "keep.txt"))))
(t/is (fs/existsSync (path/join run-dir "record.transit")))
(t/is (fs/existsSync (path/join (:dir building) "custom" "build.log")))
(t/is (= "boot executable" (fs/readFileSync boot "utf8")))
(t/is (= "unmanaged" (fs/readFileSync outside "utf8")))
(artifacts/purge root category))))
(t/deftest purge-removes-selected-subroot-symlinks-without-deleting-their-targets
(t/deftest purge-follows-category-and-nested-directory-symlinks-and-stops-cycles
(let [managed (artifacts/managed-root @temporary-root)]
(fs/mkdirSync managed #js {:recursive true})
(doseq [category [:builds :runs]]
(let [outside (path/join @temporary-root (str "outside-" (name category)))
detached (path/join @temporary-root (str "detached-" (name category)))
output (if (= category :builds) "build.transit" "run.transit")
saved (path/join outside "keep.txt")
link (path/join managed (name category))]
(write-file saved "keep symlink target")
(write-file (path/join outside "saved" output) "output, not a parsed record")
(write-file (path/join detached output) "linked output")
(write-file (path/join detached "keep.txt") "unrelated")
(fs/symlinkSync outside (path/join outside "saved" "cycle") "dir")
(fs/symlinkSync detached (path/join outside "linked") "dir")
(fs/symlinkSync outside link "dir")
(artifacts/purge @temporary-root category)
(t/is (not (fs/existsSync link)))
(t/is (.isSymbolicLink (fs/lstatSync link)))
(t/is (.isSymbolicLink (fs/lstatSync (path/join outside "saved" "cycle"))))
(t/is (.isSymbolicLink (fs/lstatSync (path/join outside "linked"))))
(t/is (not (fs/existsSync (path/join outside "saved" output))))
(t/is (not (fs/existsSync (path/join detached output))))
(t/is (fs/existsSync (path/join detached "keep.txt")))
(t/is (= "keep symlink target" (fs/readFileSync saved "utf8")))))))
(t/deftest managed-root-symlink-cannot-create-or-delete-outside-the-frontend
@ -336,23 +372,51 @@
(t/is (= "keep external build" (fs/readFileSync saved "utf8")))
(t/is (= #{"retained"} (set (array-seq (fs/readdirSync outside)))))))
(t/deftest remove-builds-keeps-other-builds-runs-and-symlink-targets
(let [removed (create-build)
(t/deftest remove-builds-cleans-selected-ids-and-keeps-directories-and-unrelated-files
(let [removed (ready-build)
kept (ready-build)
managed (artifacts/managed-root @temporary-root)
run (path/join managed "runs" "saved" "record.transit")
run (path/join managed "runs" "saved" "run.transit")
outside (path/join @temporary-root "outside" "keep.txt")
browser (path/join @temporary-root "linked-browser")
link (path/join managed "builds" "outside-link")]
(write-file run "keep run")
(write-file outside "keep target")
(write-file (path/join (:dir removed) "screenshot.PNG") "keep build screenshot")
(write-file (path/join (path/dirname outside) "build.transit") "linked build output")
(write-file (path/join browser "browser.js") "linked browser output")
(write-file (path/join browser "keep.js") "unrelated")
(fs/symlinkSync browser (path/join (path/dirname outside) "browser") "dir")
(fs/symlinkSync (path/dirname outside) link "dir")
(artifacts/remove-builds @temporary-root [(:id removed) "outside-link" "absent-build"])
(t/is (not (fs/existsSync (:dir removed))))
(t/is (not (fs/existsSync link)))
(t/is (.isDirectory (fs/statSync (:dir removed))))
(t/is (not (fs/existsSync (:manifest-path removed))))
(t/is (not (fs/existsSync (:cases-path removed))))
(t/is (fs/existsSync (path/join (:dir removed) "screenshot.PNG")))
(t/is (.isSymbolicLink (fs/lstatSync link)))
(t/is (not (fs/existsSync (path/join link "build.transit"))))
(t/is (not (fs/existsSync (path/join browser "browser.js"))))
(t/is (fs/existsSync (path/join browser "keep.js")))
(t/is (= kept (artifacts/load-build @temporary-root (:id kept))))
(t/is (= "keep run" (fs/readFileSync run "utf8")))
(t/is (= "keep target" (fs/readFileSync outside "utf8")))))
(t/deftest inside-builds-resolves-symlink-ancestors-with-missing-output-components
(let [managed (artifacts/managed-root @temporary-root)
builds (path/join managed "builds")
outside (path/join @temporary-root "external-builds")
alias (path/join @temporary-root "build-alias")]
(t/is (artifacts/inside-builds? @temporary-root (path/join builds "future" "run")))
(fs/mkdirSync managed #js {:recursive true})
(fs/mkdirSync outside)
(fs/symlinkSync outside builds "dir")
(fs/symlinkSync builds alias "dir")
(doseq [output [builds outside (path/join builds "new" "run")
(path/join outside "new" "run") (path/join alias "new" "run")]]
(t/is (artifacts/inside-builds? @temporary-root output)))
(t/is (not (artifacts/inside-builds? @temporary-root (str outside "-neighbor/run"))))
(t/is (not (artifacts/inside-builds? @temporary-root (path/join managed "runs" "new"))))))
(defn- descriptor
[id]
{:id id

View File

@ -162,6 +162,7 @@ function seedBuilds(fixture) {
manifestText(id, status, "renderer-build"),
);
fs.writeFileSync(path.join(directory, "build.log"), `${status} build\n`);
fs.writeFileSync(path.join(directory, "keep.txt"), "keep\n");
}
}
@ -174,6 +175,9 @@ function seedRuns(fixture) {
path.join(directory, "run.transit"),
manifestText(id, status, "renderer-run"),
);
fs.writeFileSync(path.join(directory, "run.log"), "run\n");
fs.writeFileSync(path.join(directory, "case-load.PNG"), "screenshot\n");
fs.writeFileSync(path.join(directory, "keep.txt"), "keep\n");
}
}
@ -399,10 +403,16 @@ test("purge deletes only the selected artifacts", testOptions, async (t) => {
stdout: "",
stderr: "",
});
assert.equal(fs.existsSync(fixture.builds), false);
for (const id of fs.readdirSync(fixture.builds)) {
assert.deepEqual(fs.readdirSync(path.join(fixture.builds, id)), [
"keep.txt",
]);
}
assert.deepEqual(tree(fixture.runs), runs);
assert.deepEqual(
tree(fixture.managed).filter(([name]) => name !== "runs"),
tree(fixture.managed).filter(
([name]) => !["runs", "builds"].includes(name),
),
retained,
);
@ -413,10 +423,14 @@ test("purge deletes only the selected artifacts", testOptions, async (t) => {
stdout: "",
stderr: "",
});
assert.equal(fs.existsSync(fixture.runs), false);
for (const id of fs.readdirSync(fixture.runs)) {
assert.deepEqual(fs.readdirSync(path.join(fixture.runs, id)), ["keep.txt"]);
}
assert.deepEqual(tree(fixture.builds), builds);
assert.deepEqual(
tree(fixture.managed).filter(([name]) => name !== "builds"),
tree(fixture.managed).filter(
([name]) => !["runs", "builds"].includes(name),
),
retained,
);
@ -426,11 +440,110 @@ test("purge deletes only the selected artifacts", testOptions, async (t) => {
stdout: "",
stderr: "",
});
assert.equal(fs.existsSync(fixture.builds), false);
assert.equal(fs.existsSync(fixture.runs), false);
assert.deepEqual(tree(fixture.managed), retained);
for (const root of [fixture.builds, fixture.runs]) {
for (const id of fs.readdirSync(root)) {
assert.deepEqual(fs.readdirSync(path.join(root, id)), ["keep.txt"]);
}
}
assert.deepEqual(
tree(fixture.managed).filter(
([name]) => !["runs", "builds"].includes(name),
),
retained,
);
});
test(
"run reserves nested output before the compiler starts",
testOptions,
async (t) => {
const fixture = createFrontend(t);
const output = path.join(fixture.managed, "missing", "parents", "output");
const marker = path.join(fixture.managed, "compiler-ran");
fs.writeFileSync(
fixture.compiler,
`#!/usr/bin/env node
const fs = require("node:fs");
if (!fs.statSync(${JSON.stringify(path.join(output, "run.log"))}).isFile()) process.exit(99);
fs.writeFileSync(${JSON.stringify(marker)}, "reserved");
process.exit(23);
`,
{ mode: 0o755 },
);
const result = await runCli(fixture, ["run", "--output-dir", output]);
assert.equal(result.exitCode, 1);
assert.equal(result.stdout, "");
assert.equal(fs.readFileSync(marker, "utf8"), "reserved");
assert.equal(fs.existsSync(path.join(output, "run.transit")), false);
},
);
test(
"run rejects invalid output parents before invoking the compiler",
testOptions,
async (t) => {
const fixture = createFrontend(t);
const parent = path.join(fixture.managed, "not-a-directory");
const marker = path.join(fixture.managed, "compiler-ran");
fs.writeFileSync(parent, "keep");
fs.writeFileSync(
fixture.compiler,
`#!/usr/bin/env node
require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");
process.exit(23);
`,
{ mode: 0o755 },
);
const result = await runCli(fixture, [
"run",
"--output-dir",
path.join(parent, "output"),
]);
assert.equal(result.exitCode, 1);
assert.match(result.stderr, /ENOTDIR|cannot create run home/);
assert.equal(fs.existsSync(marker), false);
assert.equal(fs.readFileSync(parent, "utf8"), "keep");
},
);
test(
"run rejects --remove outputs inside builds including directory aliases",
testOptions,
async (t) => {
const fixture = createFrontend(t);
const marker = path.join(fixture.managed, "compiler-ran");
const alias = path.join(path.dirname(fixture.managed), "build-alias");
fs.mkdirSync(fixture.builds);
fs.symlinkSync(fixture.builds, alias, "dir");
fs.writeFileSync(
fixture.compiler,
`#!/usr/bin/env node
require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");
process.exit(23);
`,
{ mode: 0o755 },
);
for (const root of [fixture.builds, alias]) {
const output = path.join(root, "missing", "output");
const result = await runCli(fixture, [
"run",
"--remove",
"--output-dir",
output,
]);
assert.equal(result.exitCode, 1);
assert.match(result.stderr, /--remove.*inside builds/);
assert.equal(fs.existsSync(output), false);
}
assert.equal(fs.existsSync(marker), false);
},
);
test(
"malformed purge categories leave all files intact",
testOptions,

View File

@ -12,6 +12,7 @@
[app.common.transit :as transit]
[benches.render-wasm.node.artifacts :as artifacts]
[benches.render-wasm.node.run :as run]
[benches.render-wasm.node.runs :as runs]
[benches.render-wasm.result :as result]
[cljs.test :as t :include-macros true]
[clojure.string :as str]))
@ -181,6 +182,8 @@
(execute fake cases opts (js/AbortController.)))
([_fake cases opts controller]
(run/execute (artifacts/frontend-root)
(runs/create-home {:root (artifacts/frontend-root) :run-id (:run-id opts)
:output-dir (:output-dir opts)})
(selected-build (build-dir) "test-build")
cases opts (.-signal controller) nil nil)))
@ -460,12 +463,15 @@
_ (fs/writeFileSync (path/join dir "index.html") "<!doctype html>")
fake (fake-browser {})
out (path/join @temporary-root "remove-run")
result (await (run/execute root (selected-build dir id)
home (runs/create-home {:root root :run-id "remove-run"
:output-dir out :remove true})
result (await (run/execute root home (selected-build dir id)
[(collected :case/a :fresh)]
(assoc (options fake out) :remove? true)
(.-signal (js/AbortController.)) nil nil))]
(t/is (fs/existsSync (get-in result [:home :record-path])))
(t/is (not (fs/existsSync dir)))
(t/is (fs/existsSync dir))
(t/is (not (fs/existsSync (path/join dir "index.html"))))
(t/is (= :completed (get-in result [:run :termination :reason])))
(t/is (zero? (:exit-code result)))))
@ -488,7 +494,9 @@
;; attempts have reached the saved record.
(fs/renameSync record prior)
(fs/mkdirSync record))))
response (await (run/execute root (selected-build dir id)
home (runs/create-home {:root root :run-id "failed-write"
:output-dir out :remove true})
response (await (run/execute root home (selected-build dir id)
[(collected :case/a :fresh)]
(assoc (options fake out) :remove true)
(.-signal (js/AbortController.)) nil nil))
@ -498,4 +506,5 @@
(t/is (every? zero? (vals (get-in saved [:remaining :case/a]))))
(t/is (some? (:error response)))
(t/is (= 1 (:exit-code response)))
(t/is (fs/existsSync dir))))
(t/is (= "<!doctype html>"
(fs/readFileSync (path/join dir "index.html") "utf8")))))

View File

@ -102,3 +102,29 @@
(runs/create-home {:root @temporary-root :run-id id}))))
(t/is (thrown-with-msg? js/Error #"nonempty path"
(runs/check-output-dir " "))))
(t/deftest explicit-home-creates-missing-parents
(let [out (path/join @temporary-root "missing" "parents" "run")
home (runs/create-home {:root @temporary-root :run-id "nested"
:output-dir out})]
(t/is (.isDirectory (fs/statSync out)))
(t/is (= "" (fs/readFileSync (:log-path home) "utf8")))
(t/is (thrown-with-msg? js/Error #"already exists"
(runs/create-home {:root @temporary-root :run-id "again"
:output-dir out})))))
(t/deftest remove-rejects-output-inside-builds-before-creating-it
(let [builds (path/join (artifacts/managed-root @temporary-root) "builds")
alias (path/join @temporary-root "build-alias")]
(fs/mkdirSync builds #js {:recursive true})
(fs/symlinkSync builds alias "dir")
(doseq [out [(path/join builds "direct")
(path/join alias "missing" "nested")]]
(t/is (thrown-with-msg? js/Error #"--remove.*inside builds"
(runs/create-home {:root @temporary-root :run-id "blocked"
:output-dir out :remove true})))
(t/is (not (fs/existsSync out))))
(let [out (path/join @temporary-root "builds-other" "allowed")]
(runs/create-home {:root @temporary-root :run-id "allowed"
:output-dir out :remove true})
(t/is (fs/existsSync (path/join out "run.log"))))))