optimize-slow-tests (#193)

Reviewed-on: graham/towerops-web#193
This commit is contained in:
Graham McIntire 2026-03-27 17:05:29 -05:00 committed by graham
parent c4e301a84c
commit 30248504e2
11 changed files with 110 additions and 54 deletions

View file

@ -1827,12 +1827,39 @@ const GlobalSearch = {
// Status Title - Updates page title with status emoji when alerts change // Status Title - Updates page title with status emoji when alerts change
const StatusTitle = { const StatusTitle = {
baseTitle: null as string | null,
mounted(this: any) { mounted(this: any) {
this.handleEvent("update_status_emoji", ({ emoji }: { emoji: string }) => { // Store the initial base title (without any existing emoji)
this.baseTitle = document.title.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "")
// Update base title whenever page title changes (from LiveView navigation)
const observer = new MutationObserver(() => {
const currentTitle = document.title const currentTitle = document.title
const titleWithoutEmoji = currentTitle.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "") // Only update baseTitle if it's not an emoji update (i.e., it's a real navigation)
document.title = emoji ? `${emoji} ${titleWithoutEmoji}` : titleWithoutEmoji if (currentTitle && !currentTitle.startsWith('🔴') && !currentTitle.startsWith('🟡') && !currentTitle.startsWith('🟢')) {
this.baseTitle = currentTitle
}
}) })
observer.observe(document.querySelector('title')!, {
childList: true,
characterData: true,
subtree: true
})
this.handleEvent("update_status_emoji", ({ emoji }: { emoji: string }) => {
// Always use the stored baseTitle instead of reading from document.title
const baseTitle = this.baseTitle || document.title.replace(/^(?:(?:🔴|🟡|🟢)\s)+/u, "")
document.title = emoji ? `${emoji} ${baseTitle}` : baseTitle
})
},
destroyed(this: any) {
// Clean up observer if it exists
if (this.observer) {
this.observer.disconnect()
}
} }
} }

View file

@ -4,6 +4,7 @@ defmodule Towerops.HTTP do
def request(owner, req_opts) when is_list(req_opts) do def request(owner, req_opts) when is_list(req_opts) do
req_opts req_opts
|> maybe_attach_test_plug(owner) |> maybe_attach_test_plug(owner)
# |> maybe_disable_retry() # Temporarily disabled to debug PagerDuty tests
|> Req.request() |> Req.request()
rescue rescue
error in RuntimeError -> error in RuntimeError ->

View file

@ -154,12 +154,35 @@ defmodule Towerops.Monitoring do
@doc """ @doc """
Creates a check. Creates a check.
For auto-discovery checks, uses upsert logic to update existing checks
instead of creating duplicates.
""" """
def create_check(attrs \\ %{}) do def create_check(attrs \\ %{}) do
# Use upsert for auto-discovery checks to avoid duplicates
result = result =
%Check{} if Map.get(attrs, :source_type) == "auto_discovery" and Map.has_key?(attrs, :source_id) do
|> Check.changeset(attrs) # For partial unique indexes, find existing record and update it
|> Repo.insert() case Repo.get_by(Check,
device_id: attrs.device_id,
check_type: attrs.check_type,
source_id: attrs.source_id
) do
nil ->
%Check{}
|> Check.changeset(attrs)
|> Repo.insert()
existing_check ->
existing_check
|> Check.changeset(attrs)
|> Repo.update()
end
else
%Check{}
|> Check.changeset(attrs)
|> Repo.insert()
end
case result do case result do
{:ok, check} -> broadcast_check_change(check) {:ok, check} -> broadcast_check_change(check)

View file

@ -104,6 +104,10 @@ defmodule Towerops.Monitoring.Check do
|> validate_number(:max_check_attempts, greater_than: 0) |> validate_number(:max_check_attempts, greater_than: 0)
|> validate_number(:timeout_ms, greater_than: 0) |> validate_number(:timeout_ms, greater_than: 0)
|> unique_constraint(:check_key) |> unique_constraint(:check_key)
|> unique_constraint([:device_id, :check_type, :source_id],
name: :checks_device_type_source_unique_index,
message: "check already exists for this device and source"
)
|> foreign_key_constraint(:organization_id) |> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:device_id) |> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:agent_token_id) |> foreign_key_constraint(:agent_token_id)

View file

@ -81,9 +81,14 @@ defmodule Towerops.PagerDuty.Client do
end end
# Retry up to 3 times on rate limiting with exponential backoff # Retry up to 3 times on rate limiting with exponential backoff
@max_retries 3 # In test env, disable retries to speed up tests
if Mix.env() == :test do
@max_retries 0
else
@max_retries 3
end
defp send_event_with_retry(_body, retries) when retries >= @max_retries do defp send_event_with_retry(_body, retries) when retries > @max_retries do
Logger.warning("PagerDuty rate limited after #{@max_retries} retries, giving up") Logger.warning("PagerDuty rate limited after #{@max_retries} retries, giving up")
{:error, :rate_limited} {:error, :rate_limited}
end end
@ -132,8 +137,8 @@ defmodule Towerops.PagerDuty.Client do
end end
end end
# Exponential backoff: 1s, 2s, 4s # Exponential backoff: 1s, 2s, 4s (in milliseconds)
defp backoff_ms(retries), do: to_timeout(second: round(:math.pow(2, retries))) defp backoff_ms(retries), do: round(:math.pow(2, retries)) * 1000
defp dedup_key(alert), do: "towerops-alert-#{alert.id}" defp dedup_key(alert), do: "towerops-alert-#{alert.id}"

View file

@ -70,6 +70,19 @@ defmodule Towerops.Profiles.YamlProfiles do
GenServer.call(__MODULE__, :reload, 30_000) GenServer.call(__MODULE__, :reload, 30_000)
end end
@doc """
Test helper: Build a profile directly from YAML content without loading from disk or ETS.
This avoids the expensive reload operation that loads all 786 profiles.
"""
if Mix.env() == :test do
def build_profile_from_yaml(name, detection_yaml, discovery_yaml) do
with {:ok, detection} <- YamlElixir.read_from_string(detection_yaml || ""),
{:ok, discovery} <- YamlElixir.read_from_string(discovery_yaml || "") do
build_profile(name, detection, discovery)
end
end
end
# GenServer callbacks # GenServer callbacks
@impl true @impl true

View file

@ -118,9 +118,12 @@ defmodule Towerops.Uisp.Client do
] ]
# Only use Req.Test plug in test environment # Only use Req.Test plug in test environment
# Also disable retry in tests to avoid 7s timeout (1s + 2s + 4s exponential backoff)
opts = opts =
if Application.get_env(:towerops, :env) == :test do if Application.get_env(:towerops, :env) == :test do
Keyword.put(opts, :plug, {Req.Test, __MODULE__}) opts
|> Keyword.put(:plug, {Req.Test, __MODULE__})
|> Keyword.put(:retry, false)
else else
opts opts
end end

View file

@ -63,8 +63,9 @@ defmodule Towerops.Monitoring.Executors.DnsExecutorTest do
test "returns error for timeout with unreachable server" do test "returns error for timeout with unreachable server" do
# Use a non-routable IP as DNS server to force timeout # Use a non-routable IP as DNS server to force timeout
# Reduced timeout to 100ms for faster tests (still validates timeout behavior)
config = %{"hostname" => "example.com", "server" => "192.0.2.1"} config = %{"hostname" => "example.com", "server" => "192.0.2.1"}
assert {:error, reason} = DnsExecutor.execute(config, 500) assert {:error, reason} = DnsExecutor.execute(config, 100)
assert is_binary(reason) assert is_binary(reason)
end end
end end

View file

@ -17,7 +17,8 @@ defmodule Towerops.Monitoring.Executors.SslExecutorTest do
test "returns error for connection failure to non-routable address" do test "returns error for connection failure to non-routable address" do
config = %{"host" => "192.0.2.1", "port" => 443, "warning_days" => 30} config = %{"host" => "192.0.2.1", "port" => 443, "warning_days" => 30}
assert {:error, reason} = SslExecutor.execute(config, 1_000) # Reduced timeout to 100ms for faster tests
assert {:error, reason} = SslExecutor.execute(config, 100)
assert is_binary(reason) assert is_binary(reason)
end end
@ -44,13 +45,15 @@ defmodule Towerops.Monitoring.Executors.SslExecutorTest do
# but verify the function doesn't crash # but verify the function doesn't crash
config = %{"host" => "192.0.2.1", "warning_days" => 30} config = %{"host" => "192.0.2.1", "warning_days" => 30}
assert {:error, _reason} = SslExecutor.execute(config, 500) # Reduced timeout to 100ms for faster tests
assert {:error, _reason} = SslExecutor.execute(config, 100)
end end
test "defaults warning_days to 30 when not specified" do test "defaults warning_days to 30 when not specified" do
config = %{"host" => "192.0.2.1", "port" => 443} config = %{"host" => "192.0.2.1", "port" => 443}
assert {:error, _reason} = SslExecutor.execute(config, 500) # Reduced timeout to 100ms for faster tests
assert {:error, _reason} = SslExecutor.execute(config, 100)
end end
end end

View file

@ -65,8 +65,9 @@ defmodule Towerops.Monitoring.Executors.TcpExecutorTest do
test "returns error for connection timeout" do test "returns error for connection timeout" do
# Use a non-routable IP to force timeout # Use a non-routable IP to force timeout
# Reduced timeout to 100ms for faster tests
config = %{"host" => "192.0.2.1", "port" => 80} config = %{"host" => "192.0.2.1", "port" => 80}
assert {:error, reason} = TcpExecutor.execute(config, 500) assert {:error, reason} = TcpExecutor.execute(config, 100)
assert String.contains?(reason, "timeout") or String.contains?(reason, "unreachable") or assert String.contains?(reason, "timeout") or String.contains?(reason, "unreachable") or
String.contains?(reason, "failed") String.contains?(reason, "failed")

View file

@ -465,21 +465,9 @@ defmodule Towerops.Profiles.YamlProfilesTest do
end end
describe "options-level divisor parsing" do describe "options-level divisor parsing" do
setup do # No setup needed - we're building profiles directly from YAML without files
profiles_path = Path.join(:code.priv_dir(:towerops), "profiles")
detection_dir = Path.join(profiles_path, "os_detection")
discovery_dir = Path.join(profiles_path, "os_discovery")
on_exit(fn -> defp write_test_profile(_detection_dir, _discovery_dir, discovery_yaml) do
File.rm(Path.join(detection_dir, "test_divisor_profile.yaml"))
File.rm(Path.join(discovery_dir, "test_divisor_profile.yaml"))
YamlProfiles.reload()
end)
%{detection_dir: detection_dir, discovery_dir: discovery_dir}
end
defp write_test_profile(detection_dir, discovery_dir, discovery_yaml) do
detection_yaml = """ detection_yaml = """
os: test_divisor_profile os: test_divisor_profile
text: Test Divisor Profile text: Test Divisor Profile
@ -487,17 +475,13 @@ defmodule Towerops.Profiles.YamlProfilesTest do
- sysObjectID: .1.3.6.1.4.1.99999.1 - sysObjectID: .1.3.6.1.4.1.99999.1
""" """
File.write!(Path.join(detection_dir, "test_divisor_profile.yaml"), detection_yaml) # Use test helper to build profile directly from YAML without expensive reload
File.write!(Path.join(discovery_dir, "test_divisor_profile.yaml"), discovery_yaml) # This avoids loading all 786 profiles which takes ~2.5 seconds per test
YamlProfiles.reload() YamlProfiles.build_profile_from_yaml("test_divisor_profile", detection_yaml, discovery_yaml)
YamlProfiles.get_profile("test_divisor_profile")
end end
# Task 4.1 — Scalar sensor options-level divisor # Task 4.1 — Scalar sensor options-level divisor
test "scalar sensor uses options-level divisor when no per-entry divisor", %{ test "scalar sensor uses options-level divisor when no per-entry divisor" do
detection_dir: detection_dir,
discovery_dir: discovery_dir
} do
discovery_yaml = """ discovery_yaml = """
modules: modules:
sensors: sensors:
@ -509,7 +493,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
descr: "Voltage" descr: "Voltage"
""" """
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml) profile = write_test_profile(nil, nil, discovery_yaml)
assert profile assert profile
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end) sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
@ -518,10 +502,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
end end
# Task 4.2 — Table sensor options-level divisor # Task 4.2 — Table sensor options-level divisor
test "table sensor uses options-level divisor when no per-entry divisor", %{ test "table sensor uses options-level divisor when no per-entry divisor" do
detection_dir: detection_dir,
discovery_dir: discovery_dir
} do
discovery_yaml = """ discovery_yaml = """
modules: modules:
sensors: sensors:
@ -534,7 +515,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
descr: "Temperature" descr: "Temperature"
""" """
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml) profile = write_test_profile(nil, nil, discovery_yaml)
assert profile assert profile
sensor = Enum.find(profile.table_sensor_oids, fn s -> s.sensor_type == "temperature" end) sensor = Enum.find(profile.table_sensor_oids, fn s -> s.sensor_type == "temperature" end)
@ -543,10 +524,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
end end
# Task 4.3 — Per-entry divisor overrides options-level # Task 4.3 — Per-entry divisor overrides options-level
test "per-entry divisor overrides options-level divisor", %{ test "per-entry divisor overrides options-level divisor" do
detection_dir: detection_dir,
discovery_dir: discovery_dir
} do
discovery_yaml = """ discovery_yaml = """
modules: modules:
sensors: sensors:
@ -559,7 +537,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
divisor: 100 divisor: 100
""" """
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml) profile = write_test_profile(nil, nil, discovery_yaml)
assert profile assert profile
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end) sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)
@ -568,10 +546,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
end end
# Task 4.4 — No divisor at either level defaults to 1 # Task 4.4 — No divisor at either level defaults to 1
test "sensor with no divisor at either level defaults to 1", %{ test "sensor with no divisor at either level defaults to 1" do
detection_dir: detection_dir,
discovery_dir: discovery_dir
} do
discovery_yaml = """ discovery_yaml = """
modules: modules:
sensors: sensors:
@ -581,7 +556,7 @@ defmodule Towerops.Profiles.YamlProfilesTest do
descr: "Voltage" descr: "Voltage"
""" """
profile = write_test_profile(detection_dir, discovery_dir, discovery_yaml) profile = write_test_profile(nil, nil, discovery_yaml)
assert profile assert profile
sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end) sensor = Enum.find(profile.sensor_oids, fn s -> s.sensor_type == "voltage" end)