Observe NAS removal controls before native frontend preparation

Move the existing generated watchdog and collector contract to an early, fail-closed native CI phase. Discover the complete inventory in both phases, execute every check exactly once, and preserve the remaining group assignments, native runtime tools and safety deadlines. Exercise phase admission, omission, duplicate and failure controls.

Contract-Neutral: Native CI phase ordering and inventory partition only; production installers, runtime behaviour, required checks and safety deadlines are unchanged.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-13 11:14:52 +01:00
parent c093006f1d
commit 254d7762a2
6 changed files with 308 additions and 111 deletions
+59 -45
View File
@@ -148,21 +148,6 @@ jobs:
if: matrix.unix
run: go test -count=1 -timeout 3m -run '^TestIntegrationContainer' ./scripts/installtests
- name: Set up Node.js for native runtime test assets
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Install frontend test dependencies
working-directory: frontend-modern
run: npm ci
- name: Build real frontend assets for API-linked native tests
working-directory: frontend-modern
run: npm run build
- name: Supply GNU timeout for macOS guidance checks
if: runner.os == 'macOS'
shell: bash
@@ -183,35 +168,6 @@ jobs:
set -euo pipefail
timeout --version | grep -q 'GNU coreutils'
- name: Test native agent runtime
run: >-
go test
./cmd/pulse-agent
./internal/agenttls
./internal/agentupdate
./internal/dockeragent
./internal/hostagent
./internal/kubernetesagent
./internal/remoteconfig
# The privileged helper is Linux-only. Keep its protocol, executable
# identity and shared filesystem fences in both native Linux jobs, even
# when a change leaves the unprivileged agent's source untouched. These
# tests run as the ordinary runner user; no helper service is installed.
- name: Test native Linux helper boundaries
if: runner.os == 'Linux'
run: >-
go test -race -count=1 -timeout 5m
./cmd/pulse-agent-helper
./internal/agenthelper
./internal/filesystemprobe
./internal/updatesignature
./pkg/agents/host
- name: Build native Linux helper
if: runner.os == 'Linux'
run: go build -o pulse-agent-helper-native ./cmd/pulse-agent-helper
# Server/LXC fixtures execute Linux-only scripts, not a macOS installer.
# Keep their Bash/GNU metadata, archive and rename semantics local to that step;
# native macOS agent/runtime tests still use the runner's own utilities.
@@ -246,6 +202,64 @@ jobs:
ln -s "$gnu_awk" "$fixture_bin/awk"
printf 'PULSE_INSTALLER_FIXTURE_BIN=%s\n' "$fixture_bin" >> "$GITHUB_ENV"
# Observe the existing generated NAS watchdog/collector control before
# Node acquisition and the large installer inventory. The runner's two
# phases partition the same discovered checks; no check runs twice or is
# dropped, and the production process/cleanup deadlines stay unchanged.
- name: Test NAS removal contracts before frontend preparation
if: matrix.unix
shell: bash
run: |
set -euo pipefail
if [[ -n "${PULSE_INSTALLER_FIXTURE_BIN:-}" ]]; then
export PATH="$PULSE_INSTALLER_FIXTURE_BIN:$PATH"
fi
python3 scripts/installtests/run_unix_contracts.py --phase preflight
- name: Set up Node.js for native runtime test assets
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Install frontend test dependencies
working-directory: frontend-modern
run: npm ci
- name: Build real frontend assets for API-linked native tests
working-directory: frontend-modern
run: npm run build
- name: Test native agent runtime
run: >-
go test
./cmd/pulse-agent
./internal/agenttls
./internal/agentupdate
./internal/dockeragent
./internal/hostagent
./internal/kubernetesagent
./internal/remoteconfig
# The privileged helper is Linux-only. Keep its protocol, executable
# identity and shared filesystem fences in both native Linux jobs, even
# when a change leaves the unprivileged agent's source untouched. These
# tests run as the ordinary runner user; no helper service is installed.
- name: Test native Linux helper boundaries
if: runner.os == 'Linux'
run: >-
go test -race -count=1 -timeout 5m
./cmd/pulse-agent-helper
./internal/agenthelper
./internal/filesystemprobe
./internal/updatesignature
./pkg/agents/host
- name: Build native Linux helper
if: runner.os == 'Linux'
run: go build -o pulse-agent-helper-native ./cmd/pulse-agent-helper
- name: Test native Unix installer contracts
if: matrix.unix
shell: bash
@@ -254,7 +268,7 @@ jobs:
if [[ -n "${PULSE_INSTALLER_FIXTURE_BIN:-}" ]]; then
export PATH="$PULSE_INSTALLER_FIXTURE_BIN:$PATH"
fi
python3 scripts/installtests/run_unix_contracts.py
python3 scripts/installtests/run_unix_contracts.py --phase remaining
- name: Test native Windows installer contracts
if: ${{ !matrix.unix }}
@@ -17,6 +17,8 @@ import (
type nativeGuidanceStep struct {
Name, If, Shell, Run string
ContinueOnError bool `yaml:"continue-on-error"`
WorkingDirectory string `yaml:"working-directory"`
}
func nativeGuidanceToolSteps(content []byte) (nativeGuidanceStep, nativeGuidanceStep, error) {
@@ -4,6 +4,7 @@ package installtests
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -14,7 +15,60 @@ import (
"gopkg.in/yaml.v3"
)
func nativeInstallerFixtureSteps(t *testing.T) (nativeGuidanceStep, nativeGuidanceStep) {
func nativeInstallerPhaseRun(phase string) string {
return `set -euo pipefail
if [[ -n "${PULSE_INSTALLER_FIXTURE_BIN:-}" ]]; then
export PATH="$PULSE_INSTALLER_FIXTURE_BIN:$PATH"
fi
python3 scripts/installtests/run_unix_contracts.py --phase ` + phase
}
func validateNativeInstallerPhaseOrder(steps []nativeGuidanceStep) error {
indices := map[string]int{}
names := []string{
"Set up Go",
"Supply GNU timeout for macOS guidance checks",
"Verify native Unix timeout prerequisite",
"Supply Linux server installer fixture tools on macOS",
"Test NAS removal contracts before frontend preparation",
"Set up Node.js for native runtime test assets",
"Test native agent runtime",
"Test native Unix installer contracts",
}
for i, step := range steps {
for _, name := range names {
if step.Name == name {
if _, exists := indices[name]; exists {
return fmt.Errorf("native installer phase step must run once: %s", name)
}
indices[name] = i
}
}
}
previous := -1
for _, name := range names {
index, exists := indices[name]
if !exists || index <= previous {
return fmt.Errorf("native installer preparation and both phases must stay in order: %s", name)
}
previous = index
}
supply := steps[indices[names[3]]]
if supply.If != "runner.os == 'macOS'" || supply.Shell != "bash" || supply.ContinueOnError ||
supply.WorkingDirectory != "" || strings.Contains(supply.Run, "GITHUB_PATH") {
return fmt.Errorf("Linux fixture tools must not replace the native runtime tools or hide a failure")
}
for name, phase := range map[string]string{names[4]: "preflight", names[7]: "remaining"} {
step := steps[indices[name]]
if step.If != "matrix.unix" || step.Shell != "bash" || step.ContinueOnError || step.WorkingDirectory != "" ||
strings.TrimSpace(step.Run) != nativeInstallerPhaseRun(phase) {
return fmt.Errorf("both native installer phases must execute the exact fail-closed runner with step-local tools: %s", name)
}
}
return nil
}
func nativeInstallerFixtureSteps(t *testing.T) (nativeGuidanceStep, nativeGuidanceStep, nativeGuidanceStep) {
t.Helper()
content, err := os.ReadFile(repoFile(".github", "workflows", "unified-agent-native.yml"))
if err != nil {
@@ -27,31 +81,85 @@ func nativeInstallerFixtureSteps(t *testing.T) (nativeGuidanceStep, nativeGuidan
t.Fatal(err)
}
steps := workflow.Jobs["native-agent"].Steps
runtimeAt, supplyAt, installerAt := -1, -1, -1
for i, step := range steps {
if err := validateNativeInstallerPhaseOrder(steps); err != nil {
t.Fatal(err)
}
var supply, preflight, installer nativeGuidanceStep
for _, step := range steps {
switch step.Name {
case "Test native agent runtime":
runtimeAt = i
case "Supply Linux server installer fixture tools on macOS":
supplyAt = i
supply = step
case "Test NAS removal contracts before frontend preparation":
preflight = step
case "Test native Unix installer contracts":
installerAt = i
installer = step
}
}
if runtimeAt < 0 || supplyAt <= runtimeAt || installerAt <= supplyAt {
t.Fatal("Linux fixture tool supply must follow native runtime testing and precede Unix installer checks")
return supply, preflight, installer
}
func TestNativeInstallerPhasesKeepNASBeforeFrontendAndAllChecksGating(t *testing.T) {
content, err := os.ReadFile(repoFile(".github", "workflows", "unified-agent-native.yml"))
if err != nil {
t.Fatal(err)
}
supply, installer := steps[supplyAt], steps[installerAt]
if supply.If != "runner.os == 'macOS'" || supply.Shell != "bash" || installer.If != "matrix.unix" || installer.Shell != "bash" ||
strings.Contains(supply.Run, "GITHUB_PATH") || !strings.Contains(installer.Run, `export PATH="$PULSE_INSTALLER_FIXTURE_BIN:$PATH"`) ||
!strings.Contains(installer.Run, "python3 scripts/installtests/run_unix_contracts.py") {
t.Fatal("Linux tools must stay local to complete Unix installer qualification")
var workflow struct {
Jobs map[string]struct{ Steps []nativeGuidanceStep }
}
if err := yaml.Unmarshal(content, &workflow); err != nil {
t.Fatal(err)
}
steps := workflow.Jobs["native-agent"].Steps
if err := validateNativeInstallerPhaseOrder(steps); err != nil {
t.Fatal(err)
}
indices := map[string]int{}
for i, step := range steps {
indices[step.Name] = i
}
early := indices["Test NAS removal contracts before frontend preparation"]
remaining := indices["Test native Unix installer contracts"]
for _, scenario := range []string{"missing preflight", "missing remaining", "duplicate preflight", "after Node", "before tools", "Linux only", "hidden early failure", "hidden remaining failure", "all checks repeated", "no remaining checks", "nonlocal tools", "wrong directory"} {
t.Run(scenario, func(t *testing.T) {
changed := append([]nativeGuidanceStep(nil), steps...)
switch scenario {
case "missing preflight":
changed = append(changed[:early], changed[early+1:]...)
case "missing remaining":
changed = append(changed[:remaining], changed[remaining+1:]...)
case "duplicate preflight":
changed = append(changed, changed[early])
case "after Node":
i := indices["Set up Node.js for native runtime test assets"]
changed[early], changed[i] = changed[i], changed[early]
case "before tools":
i := indices["Supply Linux server installer fixture tools on macOS"]
changed[early], changed[i] = changed[i], changed[early]
case "Linux only":
changed[early].If = "runner.os == 'Linux'"
case "hidden early failure":
changed[early].ContinueOnError = true
case "hidden remaining failure":
changed[remaining].ContinueOnError = true
case "all checks repeated":
changed[remaining].Run = nativeInstallerPhaseRun("all")
case "no remaining checks":
changed[remaining].Run = nativeInstallerPhaseRun("preflight")
case "nonlocal tools":
i := indices["Supply Linux server installer fixture tools on macOS"]
changed[i].Run += "\nprintf '%s\\n' \"$fixture_bin\" >> \"$GITHUB_PATH\"\n"
case "wrong directory":
changed[early].WorkingDirectory = "frontend-modern"
}
if err := validateNativeInstallerPhaseOrder(changed); err == nil {
t.Fatalf("accepted %s native installer phase", scenario)
}
})
}
return supply, installer
}
func TestNativeInstallerFixtureToolsAreRealAndStepLocal(t *testing.T) {
supply, installer := nativeInstallerFixtureSteps(t)
supply, preflight, installer := nativeInstallerFixtureSteps(t)
for _, scenario := range []string{"real tools", "install failure", "missing bash", "legacy bash", "missing stat", "non-GNU chmod", "missing mv", "non-GNU mv", "missing tar", "non-GNU tar", "missing awk", "non-GNU awk"} {
t.Run(scenario, func(t *testing.T) {
root := t.TempDir()
@@ -175,7 +283,7 @@ esac
// only Python's containing suite to avoid recursively starting Go.
probe := `#!/bin/sh
set -eu
test "$*" = 'scripts/installtests/run_unix_contracts.py' || exit 91
test "$*" = "scripts/installtests/run_unix_contracts.py --phase $EXPECTED_PHASE" || exit 91
test "$(command -v bash)" = "$PULSE_INSTALLER_FIXTURE_BIN/bash" || exit 92
for tool in stat chmod chown mv tar awk; do
test "$(command -v "$tool")" = "$PULSE_INSTALLER_FIXTURE_BIN/$tool" || exit 93
@@ -208,15 +316,17 @@ exit "${SUITE_EXIT:-0}"
if err := os.WriteFile(filepath.Join(bin, "python3"), []byte(probe), 0700); err != nil {
t.Fatal(err)
}
for _, code := range []string{"0", "23"} {
cmd := exec.CommandContext(ctx, "bash", "-c", installer.Run)
cmd.Env = append(env, "PULSE_INSTALLER_FIXTURE_BIN="+fixtureBin, "PROBE_ROOT="+root, "SUITE_EXIT="+code)
output, err := cmd.CombinedOutput()
if code == "0" && err != nil {
t.Fatalf("fixture-local path did not reach the suite: %v: %s", err, output)
} else if code == "23" {
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 23 {
t.Fatalf("containing suite failure hidden: %v: %s", err, output)
for phase, step := range map[string]nativeGuidanceStep{"preflight": preflight, "remaining": installer} {
for _, code := range []string{"0", "23"} {
cmd := exec.CommandContext(ctx, "bash", "-c", step.Run)
cmd.Env = append(env, "PULSE_INSTALLER_FIXTURE_BIN="+fixtureBin, "PROBE_ROOT="+root, "SUITE_EXIT="+code, "EXPECTED_PHASE="+phase)
output, err := cmd.CombinedOutput()
if code == "0" && err != nil {
t.Fatalf("fixture-local path did not reach the suite: %v: %s", err, output)
} else if code == "23" {
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 23 {
t.Fatalf("containing suite failure hidden: %v: %s", err, output)
}
}
}
}
@@ -12,7 +12,7 @@ import (
// Exercise the actual Python runner without recursively running the Go suite.
// The hosted failure had 500 checks under one package alarm; this control uses
// that inventory size and verifies admission, exact-once execution and failure
// propagation, not a claimed reproduction of macOS timing.
// propagation across both workflow phases, not a macOS timing reproduction.
func TestNativeUnixContractRunnerPartitionsWithoutDroppingChecks(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -30,16 +30,18 @@ spec.loader.exec_module(runner)
names = [f"TestFixture{i:03d}" for i in range(497)]
names += ["ExampleInstaller", "FuzzInstaller", "TestFutureFixture"]
reset = ["TestRootInstallResetSafety", "TestRootInstallResetPersistence"]
inventory = "\n".join(names + reset + ["BenchmarkIgnored", "ok package 0.1s"])
preflight = ["TestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure"]
inventory = "\n".join(names + reset + preflight + ["BenchmarkIgnored", "ok package 0.1s"])
groups = runner.partition(inventory)
assert tuple(runner.GROUPS) == ("general-0", "general-1", "reset")
assert [len(group) for group in groups] == [250, 250, 2]
assert sorted(sum(groups, [])) == sorted(names + reset)
assert len(set(sum(groups, []))) == 502
assert groups[2] == sorted(reset)
assert groups == runner.partition("\n".join(reversed(names + reset)))
assert tuple(runner.GROUPS) == ("nas-preflight", "general-0", "general-1", "reset")
assert [len(group) for group in groups] == [1, 250, 250, 2]
assert sorted(sum(groups, [])) == sorted(names + reset + preflight)
assert len(set(sum(groups, []))) == 503
assert groups[0] == preflight and groups[3] == sorted(reset)
assert groups == runner.partition("\n".join(reversed(names + reset + preflight)))
for unsafe in ["", "TestOnly", "TestOne\nTestTwo", inventory + "\nTestFixture000",
inventory + "\nTestUnsafe|TestInjected", inventory + "\nFuzzBad.*"]:
inventory + "\nTestUnsafe|TestInjected", inventory + "\nFuzzBad.*",
"\n".join(names + reset)]:
try:
runner.partition(unsafe)
except ValueError:
@@ -47,8 +49,9 @@ for unsafe in ["", "TestOnly", "TestOne\nTestTwo", inventory + "\nTestFixture000
else:
raise AssertionError("unsafe or incomplete inventory admitted")
def execute(failure_at=None, output=inventory):
def execute(failure_at=None, output=inventory, phase="all"):
calls = []
selected = groups if phase == "all" else groups[:1] if phase == "preflight" else groups[1:]
def run(argv, **kwargs):
calls.append(argv)
assert kwargs["cwd"] == runner.ROOT and kwargs["check"] is True
@@ -60,12 +63,12 @@ def execute(failure_at=None, output=inventory):
return SimpleNamespace(stdout=output)
assert argv[:6] == ["go", "test", "-count=1", "-timeout", "10m", "-run"]
assert argv[-1] == runner.PACKAGE
assert argv[6] == "^(" + "|".join(groups[len(calls) - 2]) + ")$"
assert argv[6] == "^(" + "|".join(selected[len(calls) - 2]) + ")$"
assert "-parallel" not in argv
return SimpleNamespace(returncode=0)
with patch.object(runner.subprocess, "run", side_effect=run):
try:
runner.main()
runner.main(phase)
except subprocess.CalledProcessError as error:
assert failure_at == len(calls) and error.returncode == 23
except ValueError:
@@ -75,15 +78,21 @@ def execute(failure_at=None, output=inventory):
return calls
calls = execute()
assert len(calls) == 4
assert len(calls) == 5
patterns = [re.compile(call[6]) for call in calls[1:]]
for name in names + reset:
for name in names + reset + preflight:
assert sum(bool(pattern.fullmatch(name)) for pattern in patterns) == 1
for name in ["TestFixture000/child", "TestInjected", "BenchmarkIgnored", "TestFixture000Extra"]:
assert not any(pattern.fullmatch(name) for pattern in patterns)
for failure_at in range(1, 5):
for failure_at in range(1, 6):
assert len(execute(failure_at=failure_at)) == failure_at
assert len(execute(output=inventory + "\nTestFixture000")) == 1
early = execute(phase="preflight")
remaining = execute(phase="remaining")
assert early[1:] + remaining[1:] == calls[1:]
for phase, count in [("preflight", 2), ("remaining", 4)]:
for failure_at in range(1, count + 1):
assert len(execute(failure_at=failure_at, phase=phase)) == failure_at
`, repoFile("scripts", "installtests", "run_unix_contracts.py"))
output, err := cmd.CombinedOutput()
if ctx.Err() != nil {
+27 -7
View File
@@ -6,19 +6,24 @@ fixture, transport and cleanup deadlines are unchanged. Discover the actual
package inventory so future tests, examples and fuzz seeds remain included.
The general inventory grew to 500 checks and exhausted that package alarm on
macOS Intel. Split it deterministically rather than extending safety deadlines.
Run the existing NAS process/boot-owner control before frontend preparation;
the remaining phase still requires that identity in the discovered inventory
and excludes only the check already executed by the workflow's preflight.
"""
import argparse
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[2]
PACKAGE = "./scripts/installtests"
GROUPS = ("general-0", "general-1", "reset")
PREFLIGHT = ("TestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure",)
GROUPS = ("nas-preflight", "general-0", "general-1", "reset")
def partition(output):
general, reset = [], []
preflight, general, reset = [], [], []
seen = set()
for name in output.splitlines():
if not name.startswith(("Test", "Example", "Fuzz")):
@@ -26,21 +31,34 @@ def partition(output):
if not name.isidentifier() or name in seen:
raise ValueError("invalid or repeated Go test identity")
seen.add(name)
if name in PREFLIGHT:
preflight.append(name)
(reset if name.startswith("TestRootInstallReset") else general).append(name)
if len(general) < 2 or not reset:
raise ValueError("missing general or reset installer inventory")
if set(preflight) != set(PREFLIGHT) or len(general) - len(preflight) < 2 or not reset:
raise ValueError("missing preflight, general or reset installer inventory")
# Interleave sorted identities so adjacent fixture families do not all
# consume the same package budget. Never omit or retry a discovered check.
general.sort()
return general[::2], general[1::2], sorted(reset)
# Remove the preflight only after partitioning so every other check keeps
# its existing group and order, rather than reshuffling the package budgets.
return (sorted(preflight),
[name for name in general[::2] if name not in PREFLIGHT],
[name for name in general[1::2] if name not in PREFLIGHT],
sorted(reset))
def main():
def main(phase="all"):
if phase not in ("all", "preflight", "remaining"):
raise ValueError("unknown native installer phase")
inventory = subprocess.run(
["go", "test", "-list", ".", PACKAGE], cwd=ROOT,
check=True, capture_output=True, text=True,
)
for group, names in zip(GROUPS, partition(inventory.stdout)):
if phase == "preflight" and group != "nas-preflight":
continue
if phase == "remaining" and group == "nas-preflight":
continue
print(f"Native Unix installer {group}: {len(names)} discovered checks", flush=True)
subprocess.run(
["go", "test", "-count=1", "-timeout", "10m", "-run", "^(" + "|".join(names) + ")$", PACKAGE],
@@ -49,4 +67,6 @@ def main():
if __name__ == "__main__":
main()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--phase", choices=("all", "preflight", "remaining"), default="all")
main(parser.parse_args().phase)
@@ -17,33 +17,44 @@ spec.loader.exec_module(runner)
class NativeInstallerRunnerTest(unittest.TestCase):
inventory = "TestInstaller\nTestRootInstallResetSafety\nExample\nFuzzInput\nTestFuture\nTestRootInstallResetFuture\nok\tpackage\n"
inventory = "TestInstaller\nTestRootInstallResetSafety\nExample\nFuzzInput\nTestFuture\nTestRootInstallResetFuture\nTestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure\nok\tpackage\n"
expected_groups = (
["Example", "TestFuture"],
["FuzzInput", "TestInstaller"],
["TestInstallSHNASBootOwnersQuiesceBeforeRecoveryErasure"],
["Example", "TestFuture", "TestInstaller"],
["FuzzInput"],
["TestRootInstallResetFuture", "TestRootInstallResetSafety"],
)
def test_every_discovered_check_runs_once_in_one_group(self):
groups = runner.partition(self.inventory)
self.assertEqual(runner.GROUPS, ("general-0", "general-1", "reset"))
self.assertEqual(runner.GROUPS, ("nas-preflight", "general-0", "general-1", "reset"))
self.assertEqual(groups, self.expected_groups)
selected = [name for group in groups for name in group]
self.assertEqual(len(selected), 6)
self.assertEqual(len(set(selected)), 6)
self.assertEqual(len(selected), 7)
self.assertEqual(len(set(selected)), 7)
def test_partition_order_and_odd_inventory_are_exhaustive(self):
reversed_inventory = "\n".join(reversed(self.inventory.splitlines()))
self.assertEqual(runner.partition(reversed_inventory), self.expected_groups)
self.assertEqual(runner.partition(self.inventory + "TestZ\n"), (
["Example", "TestFuture", "TestZ"],
self.expected_groups[0],
self.expected_groups[1],
self.expected_groups[2],
["FuzzInput", "TestZ"],
self.expected_groups[3],
))
def test_other_checks_keep_their_previous_group_and_order(self):
general = sorted(name for name in self.inventory.splitlines()
if name.startswith(("Test", "Example", "Fuzz"))
and not name.startswith("TestRootInstallReset"))
groups = runner.partition(self.inventory)
for group, previous in zip(groups[1:3], (general[::2], general[1::2])):
self.assertEqual(group, [name for name in previous if name not in runner.PREFLIGHT])
def test_missing_inventory_is_not_a_passing_empty_run(self):
for inventory in ("", "ok\tpackage\n", "TestInstaller\n", "TestRootInstallResetSafety\n",
"TestInstaller\nTestRootInstallResetSafety\n"):
"TestInstaller\nTestRootInstallResetSafety\n",
self.inventory.replace(runner.PREFLIGHT[0] + "\n", "")):
with self.subTest(inventory=inventory), self.assertRaises(ValueError):
runner.partition(inventory)
@@ -54,9 +65,9 @@ class NativeInstallerRunnerTest(unittest.TestCase):
def test_unicode_go_identifiers_remain_included(self):
groups = runner.partition(self.inventory + "TestΣ\n")
self.assertIn("TestΣ", [name for group in groups[:2] for name in group])
self.assertIn("TestΣ", [name for group in groups[1:3] for name in group])
def run_main(self, fault=None):
def run_main(self, fault=None, phase="all"):
calls = []
def run(command, **kwargs):
@@ -68,10 +79,10 @@ class NativeInstallerRunnerTest(unittest.TestCase):
with mock.patch.object(runner.subprocess, "run", side_effect=run), mock.patch("builtins.print"):
if fault:
with self.assertRaises(subprocess.CalledProcessError) as raised:
runner.main()
runner.main(phase)
self.assertEqual(raised.exception.returncode, 23)
else:
runner.main()
runner.main(phase)
return calls
def test_actual_commands_preserve_deadlines_count_and_complete_selection(self):
@@ -81,7 +92,7 @@ class NativeInstallerRunnerTest(unittest.TestCase):
self.assertTrue(calls[0][1]["text"])
# Check the count before zip: a missing final reset invocation must not
# silently truncate the comparison and become a passing control.
self.assertEqual(len(calls), 4)
self.assertEqual(len(calls), 5)
for (command, kwargs), names in zip(calls[1:], self.expected_groups):
self.assertEqual(command, ["go", "test", "-count=1", "-timeout", "10m", "-run", "^(" + "|".join(names) + ")$", runner.PACKAGE])
self.assertTrue(kwargs["check"])
@@ -89,7 +100,7 @@ class NativeInstallerRunnerTest(unittest.TestCase):
def test_command_selectors_do_not_broaden_the_inventory(self):
calls = self.run_main()
self.assertEqual(len(calls), 4)
self.assertEqual(len(calls), 5)
patterns = [re.compile(command[6]) for command, _ in calls[1:]]
for name in (name for group in self.expected_groups for name in group):
with self.subTest(name=name):
@@ -101,15 +112,46 @@ class NativeInstallerRunnerTest(unittest.TestCase):
def test_inventory_failure_does_not_run_any_group(self):
self.assertEqual(len(self.run_main(1)), 1)
def test_first_general_failure_cannot_be_hidden_by_later_groups(self):
def test_preflight_failure_cannot_be_hidden_by_later_groups(self):
self.assertEqual(len(self.run_main(2)), 2)
def test_second_general_failure_remains_terminal(self):
def test_first_general_failure_remains_terminal(self):
self.assertEqual(len(self.run_main(3)), 3)
def test_reset_failure_remains_terminal(self):
def test_second_general_failure_remains_terminal(self):
self.assertEqual(len(self.run_main(4)), 4)
def test_reset_failure_remains_terminal(self):
self.assertEqual(len(self.run_main(5)), 5)
def test_workflow_phases_execute_the_same_inventory_once(self):
early = self.run_main(phase="preflight")
remaining = self.run_main(phase="remaining")
self.assertEqual(len(early), 2)
self.assertEqual(len(remaining), 4)
self.assertEqual(early[1][0], self.run_main()[1][0])
self.assertEqual(remaining[1:], self.run_main()[2:])
patterns = [re.compile(command[6]) for command, _ in early[1:] + remaining[1:]]
for name in (name for group in self.expected_groups for name in group):
with self.subTest(name=name):
self.assertEqual(sum(bool(pattern.fullmatch(name)) for pattern in patterns), 1)
def test_each_phased_failure_stops_without_retry(self):
for phase, count in (("preflight", 2), ("remaining", 4)):
for fault in range(1, count + 1):
with self.subTest(phase=phase, fault=fault):
self.assertEqual(len(self.run_main(fault, phase)), fault)
def test_unknown_phase_cannot_start_go_or_hide_checks(self):
with mock.patch.object(runner.subprocess, "run") as run:
with self.assertRaises(ValueError):
runner.main("skip")
run.assert_not_called()
result = subprocess.run(["python3", str(ROOT / "scripts/installtests/run_unix_contracts.py"),
"--phase", "skip"], capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
self.assertIn("invalid choice", result.stderr)
if __name__ == "__main__":
unittest.main()