This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new b01b11f818 test(amber): cover PveManager system-package resolution and
package deletion (#8036)
b01b11f818 is described below
commit b01b11f8182313b9f3bff877c661f7ae0f0b5a1c
Author: Xinyuan Lin <[email protected]>
AuthorDate: Thu Aug 27 08:21:43 2026 +0000
test(amber): cover PveManager system-package resolution and package
deletion (#8036)
### What changes were proposed in this PR?
Six tests added to `PveResourceSpec`, covering `PveManager`'s error
paths. That file already owns this class's tests — 15 `"PveManager"
should` blocks — so this extends it rather than adding a second spec.
| Metric | Before | After |
|---|---|---|
| **Codecov (fully-covered lines)** | 198/228 = 86.8% | **205/228 =
89.9%** |
| Branch arms | 53/140 | 60/140 |
**+7 fully-covered lines and +7 branch arms.** The newly covered lines
are the three give-up arms of `resolveSystemPackages` (149/151, 170/172,
181/183) and the uninstall-failure arm of `deletePackages` (565) — all
plain control flow, no `logger.info`/`debug` bodies, so none is a line
that looks green locally and dies in CI.
These figures are the **CI-equivalent projection**, not the raw local
numbers. `PveResourceSpec` has 6 pre-existing Windows-only failures —
its fake runner fabricates a POSIX `<venv>/bin/python` while
`PveManager` resolves `<venv>/Scripts/python.exe` — so the raw local
delta is inflated and is deliberately not quoted. The projection was
built by applying a throwaway test-file scaffold that makes the runner
platform-aware, measuring both sides with it applied byte-identically,
then reverting it.
**On the absolute endpoints:** two independent measurements agree on +7
but differ by 3 on the endpoints (198 → 205 vs 195 → 202). The delta is
solid and reproduced three times; the endpoint is the softer number. I
would rather say that than publish a figure with more confidence than it
has.
### What the reviewers found
This bundle went through a mutation audit and then a separate vacuity
review, and **each found real defects the other could not**.
The mutation audit's most serious catch: the resolver tests recorded
what the runner was handed but asserted only its *length*, never *what
ran*. Retargeting the install from the throwaway venv to
`PythonUtils.getPythonExecutable` was **byte-identical to baseline** —
meaning `resolveSystemPackages` would pip-install `requirements.txt`
into the machine's system Python and report that interpreter's package
set as "the system set", with the test named *"give up without
attempting the install"* unable to tell the difference.
The vacuity review then found three things a mutation audit structurally
cannot:
- **The throwaway-venv cleanup was executed four times per run and
constrained by nothing.** Swapping `Comparator.reverseOrder()` for
`naturalOrder()` makes the delete hit the non-empty parent first, throw,
and get swallowed by the block's own `catch` — the entire temp tree
leaks, and the suite stayed green.
- **The sanitiser's composition order was unpinned.** Each rule (trim,
drop-blank, drop-comment) was pinned individually, but exchanging
`.map(_.trim).filter(…)` for `.filter(…).map(_.trim)` survived, because
every fixture line landed the same way either way. Fixed by indenting
the `## FIXME` fixture line two spaces — it is a comment only *after*
trimming, so it is the one line that separates the two orders.
- **Three comments staked the fixture's whole rationale on the
`--constraint` file, and the word appeared in the spec only inside
comments.** No assertion referenced the flag, the file, or its contents.
Two mutants were therefore unkillable — including one that keeps package
*names* but drops every *version pin*, leaving pip free to resolve any
`pyarrow`.
All are now killed by a named test. The new `--constraint` test adds
**zero** coverage — its path was already covered — and is included
purely as a mutation-strength test; it is the sole killer of the
constraint-file mutant.
### Verification
Four load-bearing mutants were re-run against the final content, one at
a time, each with a uniqueness-asserted anchor, reverted from a scratch
snapshot, with the production diff verified empty and an md5 match
before every subsequent compile. **All four killed**, each by a named
test.
One published mutation row was discarded rather than reported: applied
exactly as written it failed to *compile* (`forward reference to value
collected`), and a compile-only mutant proves nothing. It was replaced
with a semantically equivalent hoisted variant that does compile and
does die.
**No regression.** The 6 pre-existing failures are identical in *name*
on `main` and on this branch, not merely equal in count. All six new
tests pass on Windows unscaffolded.
### Deliberately not included
`resolveSystemPackages` **fails open** and this PR does not pin that as
correct. All three give-up arms return `Seq.empty`, and callers degrade
silently: `systemPackageNames` becomes empty, so a user may install or
delete any package including one the Python workers depend on, and the
`--constraint` file becomes empty, so user installs are no longer
pinned. The tests assert the *giving up* — that the next command never
runs, that a partial freeze is discarded — which is unambiguously right.
No assertion blesses "empty set" as the correct answer for the
application, and the spec says so. Fixing it is a production change.
Two survivor probes are recorded rather than pinned: the three
`logger.error` calls can all be deleted with the suite outcome-identical
(asserting on log lines means attaching an appender inside a shared,
strictly-serial JVM, and those lines stay Codecov-missed regardless
because of the scalalogging guard arm), and the cleanup `finally` block
can be replaced with `()` — those lines already count as hits through
try/finally bytecode duplication, so pinning them is worth zero.
`systemPackages` / `systemPackageNames` / `systemConstraintFile` are
JVM-lifetime lazy vals, so resolution can be driven exactly once per JVM
and the public `getSystemPackages` can never be exercised against a
failing runner. The tests reach the private method reflectively; a
rename yields `NoSuchMethodException`, i.e. a test error rather than a
false pass, which was verified.
No production file is touched.
### Any related issues, documentation, discussions?
Closes #8034
### How was this PR tested?
```
sbt "WorkflowExecutionService/testOnly
org.apache.texera.web.resource.pythonvirtualenvironment.*"
```
```
[info] Total number of tests run: 57
[info] Tests: succeeded 51, failed 6, canceled 1, ignored 0, pending 0
```
The 6 failures are the pre-existing Windows-only ones described above
and are present on `main` unchanged; under the CI-equivalence scaffold
the same scope reports `succeeded 57, failed 0`.
`WorkflowExecutionService/Test/scalafmtCheck` and
`WorkflowExecutionService/Test/scalafix --check` both pass.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
---
.../pythonvirtualenvironment/PveResourceSpec.scala | 229 ++++++++++++++++++++-
1 file changed, 227 insertions(+), 2 deletions(-)
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
index 926aa87ada..dd59386e64 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
@@ -58,10 +58,42 @@ class PveResourceSpec
private var installExit = 0
private var uninstallExit = 0
+ // Kept separate from installExit so a test can let `pip freeze` print a
package
+ // and *still* fail — a freeze that dies halfway is the case where "return
what
+ // was collected" and "return nothing" differ.
+ private var freezeExit = 0
+
+ // When set, the mocked runner throws for a `pip uninstall` instead of
returning
+ // an exit code. `Process.!` throws rather than returning non-zero when the
+ // interpreter has gone missing or is not executable, which is reachable
here:
+ // nothing stops the venv directory being removed between the guard and the
spawn.
+ private var uninstallThrows = false
+
+ // Every command the mocked runner is handed, in order. Lets a test assert
what
+ // did *not* run, which is the only thing that separates "gave up at this
step"
+ // from "ran everything and happened to produce nothing".
+ private val recordedCommands =
scala.collection.mutable.ListBuffer[Seq[String]]()
+
// What the mocked `pip freeze` reports as the resolved system set. pyarrow
is
// always a hard dependency in amber/requirements.txt, so it stands in for "a
// system package the user may neither install nor delete".
- private val systemFreeze = Seq("pyarrow==23.0.1")
+ //
+ // Deliberately not clean: a real `pip freeze` emits blank lines and `##
FIXME:`
+ // comment lines for editable/VCS installs it cannot pin, and surrounding
whitespace
+ // is not guaranteed. Those lines must not reach `systemPackages`, because
they are
+ // written verbatim into the `--constraint` file every user install is
pinned against,
+ // where a malformed line makes pip abort. A fixture of one already-trimmed,
+ // already-non-comment line leaves the resolver's sanitising step
unconstrained.
+ //
+ // The `## FIXME:` line is indented too, which is what makes the *order* of
the two
+ // sanitising steps observable: trimming and then filtering is not the same
as filtering
+ // and then trimming, and an indented comment is the only line that can tell
them apart —
+ // it is a comment only once the leading whitespace is gone.
+ private val systemFreeze =
+ Seq("", " ## FIXME: could not find svn location", " pyarrow==23.0.1 ")
+
+ // What the resolver is required to turn `systemFreeze` into.
+ private val expectedSystemPackages = Seq("pyarrow==23.0.1")
private val realRunner = PveManager.runProcess
@@ -79,6 +111,7 @@ class PveResourceSpec
runProcessMock
.expects(*, *, *)
.onCall { (command: Seq[String], _: Seq[(String, String)], logger:
ProcessLogger) =>
+ recordedCommands += command
if (command.contains("venv")) {
if (venvExit == 0) {
val bin = Paths.get(command.last).resolve("bin")
@@ -92,9 +125,14 @@ class PveResourceSpec
venvExit
} else if (command.contains("freeze")) {
systemFreeze.foreach(line => logger.out(line))
- 0
+ freezeExit
} else if (command.contains("uninstall")) {
+ if (uninstallThrows) throw new java.io.IOException("boom")
logger.out("mock uninstall")
+ // A failing pip writes the reason to stderr; that is the only line
telling the
+ // user *why* the delete failed, so the fixture has to produce one
for the
+ // stderr arm of deletePackages' ProcessLogger to be reachable at
all.
+ if (uninstallExit != 0) logger.err("ERROR: Cannot uninstall
colorama")
uninstallExit
} else if (command.contains("install")) {
logger.out("mock install")
@@ -123,6 +161,9 @@ class PveResourceSpec
venvExit = 0
installExit = 0
uninstallExit = 0
+ freezeExit = 0
+ uninstallThrows = false
+ recordedCommands.clear()
testPveName = s"testenv${System.currentTimeMillis()}"
testRoot = Paths.get("/tmp/texera-pve/venvs").resolve(testCuid.toString)
queue = new LinkedBlockingQueue[String]()
@@ -147,6 +188,40 @@ class PveResourceSpec
queue.iterator().asScala.toList.mkString("\n")
}
+ /**
+ * Puts just the interpreter where PveManager looks for it, without going
through
+ * `createNewPve`. The mocked venv creation fabricates a POSIX `bin/python`
layout,
+ * so the create flow cannot stand a PVE up on a platform whose interpreter
lives
+ * somewhere else; `pythonBinFor` asks PveManager's own question instead.
+ */
+ private def fabricatePve(pveName: String): Path = {
+ val python = pythonBinFor(pveName)
+ Files.createDirectories(python.getParent)
+ Files.write(python, Array.emptyByteArray)
+ python.toFile.setExecutable(true)
+ python
+ }
+
+ /** Where PveManager records the packages the user installed into a PVE. */
+ private def userPackagesFile(pveName: String): Path =
+ testRoot.resolve(pveName).resolve("user-packages.txt")
+
+ /**
+ * Calls `PveManager.resolveSystemPackages()` directly.
+ *
+ * Its public face is `getSystemPackages`, which reads the `systemPackages`
lazy val.
+ * That val is memoised for the life of the JVM and amber's suites share
one, so a test
+ * that forced it through a failing runner would fix the system package set
at empty for
+ * every suite that follows — starting with this spec's own two
system-package tests.
+ * Reaching the resolver directly is what makes its failure arms testable
without that
+ * side effect; there is no seam that would let an ordinary call do the
same.
+ */
+ private def resolveSystemPackages(): Seq[String] = {
+ val method = PveManager.getClass.getDeclaredMethod("resolveSystemPackages")
+ method.setAccessible(true)
+ method.invoke(PveManager).asInstanceOf[Seq[String]]
+ }
+
/**
* A computing-unit id whose venv directory does not exist on this machine.
* PveManager.getEnvironments lists /tmp/texera-pve/venvs/<cuid> directly,
so a fixed id
@@ -639,4 +714,154 @@ class PveResourceSpec
s"[PVE][ERR] Python executable not found for PVE:
${pythonBinFor(absent).toAbsolutePath}"
}
+ /*
+ * The uninstall's two unhappy endings. Both are about the same thing: the
recorded package
+ * list is a claim about what is installed in the venv, so it may only
change when pip
+ * actually removed something.
+ */
+ it should "report the failure and leave the recorded package list alone when
pip uninstall exits non-zero" in {
+ expectProcessCalls()
+ fabricatePve(testPveName)
+ val metadata = userPackagesFile(testPveName)
+ Files.write(metadata, Seq("colorama==0.4.6").asJava)
+ uninstallExit = 1
+
+ val output = PveManager.deletePackages(testCuid, "colorama", testPveName)
+
+ output should contain("[PVE][ERR] Failed to uninstall package: colorama")
+ output should not contain "[PVE] Uninstalled colorama successfully"
+ // colorama is still in the venv, so the manifest has to keep saying so.
+ Files.readAllLines(metadata).asScala should contain("colorama==0.4.6")
+
+ // What was actually handed to pip. `PIP_NO_INPUT=1` is set in pipEnv, so
an
+ // uninstall missing `-y` aborts instead of prompting — dropping it would
break
+ // every user package deletion while leaving every exit-code assertion
above green,
+ // because the fake runner answers on the command's *shape*, not its argv.
+ val uninstall = recordedCommands.last
+ uninstall should contain("uninstall")
+ uninstall should contain("-y")
+ uninstall.last shouldBe "colorama"
+ uninstall.head shouldBe pythonBinFor(testPveName).toAbsolutePath.toString
+
+ // pip's own output is the payload PveResource.deletePackage hands back to
the UI;
+ // both streams have to survive the trip, or "report the failure" reports
nothing
+ // but PveManager's own generic wrapper line.
+ output should contain("[pip] mock uninstall")
+ output should contain("[pip][ERR] ERROR: Cannot uninstall colorama")
+ }
+
+ it should "return an error list rather than throwing when the uninstall
cannot be spawned" in {
+ expectProcessCalls()
+ fabricatePve(testPveName)
+ uninstallThrows = true
+
+ val output = PveManager.deletePackages(testCuid, "colorama", testPveName)
+
+ // The caller is a JAX-RS resource that turns this list into a 200/400; an
escaping
+ // exception would reach it as a 500 with no message about which PVE
failed.
+ output should have size 1
+ output.head should include(s"cuid=$testCuid")
+ output.head should include("boom")
+ }
+
+ /*
+ * The other half of what the resolved system set is for. Refusing to
install a package
+ * whose name collides with a system one is covered above; this is the
constraint file,
+ * which is how the resolved *versions* reach pip. Without it a user install
is free to
+ * pull a different pyarrow in as a transitive dependency of something else
and break the
+ * Python workers, and the install still reports success.
+ */
+ "PveManager.installUserPackages" should "pin the install against the
resolved system versions" in {
+ expectProcessCalls()
+ fabricatePve(testPveName)
+
+ PveManager.installUserPackages(List("colorama==0.4.6"), testCuid, queue,
testPveName)
+
+ val install = recordedCommands.last
+ install should contain("--constraint")
+
+ // pip reads the path as the argument of the flag, so the two travel
together and in
+ // this order; handing it the package name instead is still a well-formed
command line.
+ val constraintFile = Paths.get(install(install.indexOf("--constraint") +
1))
+
+ // Equality, against the same sanitised set the resolver is required to
produce: this
+ // file is where those lines end up, and it is the reason the blank and
`## FIXME:`
+ // lines of `systemFreeze` may not survive resolution — pip aborts the
whole install
+ // on a constraint line it cannot parse.
+ Files.readAllLines(constraintFile).asScala.toSeq shouldBe
expectedSystemPackages
+ }
+
+ /*
+ * `resolveSystemPackages`' three give-up arms.
+ *
+ * The resolved set is what stops a user from installing a package that
shadows one the
+ * Python workers depend on, so what matters at each failure is that the
resolver stops
+ * there rather than carrying a half-built answer forward: no pip install
into a venv that
+ * was never created, no `pip freeze` of a venv whose install failed, and no
partial freeze
+ * promoted to "the system set".
+ *
+ * These assert the giving up, not that an empty set is a good answer to
give the rest of
+ * the app — an empty set is what `systemPackageNames` and `--constraint`
both degrade to,
+ * which is fail-open. That is a property of the caller, and it is not
pinned here.
+ */
+ "PveManager's system-package resolution" should "give up without attempting
the install when the throwaway venv cannot be created" in {
+ expectProcessCalls()
+ venvExit = 1
+
+ resolveSystemPackages() shouldBe empty
+
+ recordedCommands should have size 1
+ recordedCommands.head should contain("venv")
+ }
+
+ it should "give up without freezing when the requirements install fails" in {
+ expectProcessCalls()
+ installExit = 1
+
+ resolveSystemPackages() shouldBe empty
+
+ recordedCommands should have size 2
+ recordedCommands.exists(_.contains("freeze")) shouldBe false
+
+ // The install has to go into the throwaway venv, whose directory is the
last argument
+ // of the create that preceded it. Aiming it at the system interpreter
instead still
+ // produces two commands in the right order and still returns empty here,
so the count
+ // above cannot tell the two apart — and getting it wrong would pip-install
+ // amber/requirements.txt straight into the machine's Python.
+ recordedCommands(1).head should startWith(recordedCommands.head.last)
+
+ // The throwaway venv is a real directory tree by this point — the create
step above
+ // succeeded, so it holds the interpreter the fake runner fabricated, as a
real
+ // `python -m venv` would. Giving up early must still take it away with
it: nothing
+ // revisits the directory afterwards, and its name is a fresh temp path
each time, so
+ // whatever is left there is left for good.
+ Files.exists(Paths.get(recordedCommands.head.last)) shouldBe false
+ }
+
+ it should "discard what a failing pip freeze already printed" in {
+ expectProcessCalls()
+
+ // Control: with the freeze succeeding, this same fixture does produce a
system set. Without
+ // it, the assertion below would hold just as well for a resolver that
returned whatever the
+ // freeze printed, because a fixture that printed nothing would look
identical.
+ //
+ // Pinned as an equality, not a `contain`: `systemFreeze` deliberately
includes a blank
+ // line and a `## FIXME:` comment, and the exact answer is what says those
are dropped
+ // and the surviving line is trimmed. A `contain` would pass on a resolver
that handed
+ // the raw freeze output through into the `--constraint` file.
+ resolveSystemPackages() shouldBe expectedSystemPackages
+
+ // The freeze has to interrogate the throwaway venv, not whatever
interpreter is on PATH:
+ // freezing the system Python would report that machine's packages as "the
system set".
+ recordedCommands.last.head should startWith(recordedCommands.head.last)
+
+ recordedCommands.clear()
+ freezeExit = 1
+
+ // The mock still emits pyarrow==23.0.1 before reporting the failure, so a
resolver that
+ // kept the collected lines would answer with a system set of one.
+ resolveSystemPackages() shouldBe empty
+ recordedCommands.last should contain("freeze")
+ }
+
}