diff --git a/docs/basic-command-line-interface-cli.md b/docs/basic-command-line-interface-cli.md index f2e005e..a444bea 100644 --- a/docs/basic-command-line-interface-cli.md +++ b/docs/basic-command-line-interface-cli.md @@ -114,8 +114,12 @@ robotdashboard -r index=0,index=1:4;9,index=10 robotdashboard --removeruns 'run_start=2024-07-30 15:27:20.184407,index=20' robotdashboard -r alias=some_cool_alias,tag=prod,tag=dev -r alias=alias12345 robotdashboard -r limit=10 -robotdashboard -r age=10d # (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported -robotdashboard -r age=-10d +robotdashboard -r limit=10,tag=nightly # keep 10 newest 'nightly' runs, leave others +robotdashboard -r limit=10,tag=nightly,tag=prod # scope to multiple tags +robotdashboard -r age=10d # remove runs OLDER than 10 days. (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported +robotdashboard -r age=-10d # remove runs YOUNGER than 10 days (note the leading minus) +robotdashboard -r age=10d,tag=nightly # remove 'nightly' runs older than 10 days, leave others +robotdashboard -r age=10d,tag=nightly,tag=prod # scope to multiple tags # Log data of removed runs in jsonl robotdashboard -r limit=10 --logremoved "/myLogDir/removedRuns.jsonl" robotdashboard -r limit=10 --logremoved "run:suite:/myLogDir/removedRuns.jsonl" @@ -128,8 +132,12 @@ robotdashboard -r limit=10 --logremoved run:keyword - Index ranges use `:` for ranges and `;` for lists. - Quotation marks are required when spaces exist in identifiers. - With limit=10 only the 10 most recent runs will be kept, all others will be removed. +- With limit=10,tag=nightly only the 10 most recent runs **carrying that tag** are kept; older tagged runs are removed and runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags. - With age=10d only runs _**older**_ than 10 days will be removed -- With age=-10d only runs _**younger**_ than 10 days will be removed +- With age=-10d (leading minus) only runs _**younger**_ than 10 days will be removed +- Supported age units: (y)ear, (d)ay, (h)our, (m)inute, (s)econd — e.g. `age=12h`, `age=-30m` +- With age=10d,tag=nightly only runs **carrying that tag** older than 10 days are removed; runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags. +- `tag` can be combined with **either** `limit` **or** `age` (not both at once — that is an error). The combination is detected automatically and an `INFO` line explains exactly what will be deleted; if other options (index/alias/run_start) are present too, the deletion order is printed. - Optional: `--logremoved` logs run data to a `.jsonl` file before removal. - Format: `[types:]path` where types are colon-separated from `run`, `suite`, `test`, `keyword`, `all`. - If no types are specified, defaults to `all` (runs, suites, tests and keywords). diff --git a/docs/dashboard-server.md b/docs/dashboard-server.md index 3a4118f..7c909ba 100644 --- a/docs/dashboard-server.md +++ b/docs/dashboard-server.md @@ -161,6 +161,9 @@ The admin page supports four methods for adding test results: | **By Alias** | Comma-separated alias names. | | **By Tag** | Comma-separated tags — removes all runs matching any of the specified tags. | | **By Limit** | Keep only the N most recent runs; all older runs are deleted. | +| **By Limit + Tag(s)** | Supply `limit` together with `tags` to scope the limit: the N most recent runs matching any given tag are kept, older matching runs are deleted, and runs without those tags are left untouched (e.g. `{"limit": 10, "tags": ["nightly"]}`). | +| **By Age** | Remove runs by age threshold. `"10d"` removes runs **older** than 10 days; a leading minus `"-10d"` removes runs **younger** than 10 days. Units: (y)ear, (d)ay, (h)our, (m)inute, (s)econd. | +| **By Age + Tag(s)** | Supply `age` together with `tags` to scope the age threshold: only runs matching any given tag within the age range are deleted, runs without those tags are left untouched (e.g. `{"age": "10d", "tags": ["nightly"]}`). | | **Remove All** | Irreversibly deletes all runs from the database. | > **Note:** When a run is removed, the server automatically checks whether a corresponding log file exists in the `robot_logs/` folder (derived by replacing `output` → `log` and `.xml` → `.html` in the stored path). If found, it is deleted alongside the run. The console response will confirm whether a log was removed or note that none was found. diff --git a/robotframework_dashboard/arguments.py b/robotframework_dashboard/arguments.py index 4db71db..5959081 100644 --- a/robotframework_dashboard/arguments.py +++ b/robotframework_dashboard/arguments.py @@ -69,6 +69,92 @@ def _check_project_version_usage(self, tags, arguments): print(" ERROR: Mixing --projectversion and version_ tags not supported") exit(2) + def _process_remove_runs(self, parts): + """Process and validate the comma-separated --removeruns parts. + + Detects a scoped retention combination ('limit'+'tags' or 'age'+'tags') + and rewrites it into the internal 'limit=N;tag=...' / 'age=X;tag=...' + form so the retention acts on the tagged subset instead of running as + independent operations. This lets users combine options with the regular + comma separator (e.g. '-r "limit=10,tag=nightly"') instead of ';'. + + Rules: + - 'tags' may be combined with EITHER 'limit' OR 'age', not both (error). + - Only one 'limit'/'age' may be combined with tags (error otherwise). + - When a combination is detected an INFO message explains exactly what + will be deleted; if other options are also present, the deletion order + is printed too. + """ + limit_parts = [p for p in parts if p.startswith("limit=")] + age_parts = [p for p in parts if p.startswith("age=")] + tag_parts = [p for p in parts if p.startswith("tag=")] + other_parts = [ + p + for p in parts + if not ( + p.startswith("limit=") + or p.startswith("age=") + or p.startswith("tag=") + ) + ] + has_limit, has_age, has_tag = bool(limit_parts), bool(age_parts), bool(tag_parts) + + # No tags, or tags without a retention partner -> no combination, + # behave exactly as before (every part is an independent operation). + if not has_tag or (not has_limit and not has_age): + return parts + + # Tags require exactly one retention partner. + if has_limit and has_age: + print( + " ERROR: Cannot combine 'limit' and 'age' with 'tags' at the same time.\n" + " Provide either 'limit'+'tag(s)' (keep N newest tagged runs) OR\n" + " 'age'+'tag(s)' (remove tagged runs in an age range), not both." + ) + exit(3) + if len(limit_parts) > 1: + print(" ERROR: Only one 'limit' may be combined with 'tag(s)'.") + exit(4) + if len(age_parts) > 1: + print(" ERROR: Only one 'age' may be combined with 'tag(s)'.") + exit(5) + + tag_values = [p.replace("tag=", "") for p in tag_parts] + tag_suffix = "".join(f";tag={value}" for value in tag_values) + tag_list = ", ".join(tag_values) + if has_limit: + retention = limit_parts[0] + limit_value = retention.replace("limit=", "") + combo_message = ( + f" INFO: Combining 'limit' with 'tag(s)' -> keeping the {limit_value} most recent " + f"run(s) tagged with [{tag_list}] and removing older matching runs " + f"(runs without these tags are left untouched)." + ) + else: + retention = age_parts[0] + age_value = retention.replace("age=", "") + direction = "younger than" if age_value.startswith("-") else "older than" + combo_message = ( + f" INFO: Combining 'age' with 'tag(s)' -> removing run(s) tagged with " + f"[{tag_list}] that are {direction} {age_value.lstrip('-')} " + f"(runs without these tags are left untouched)." + ) + combo = f"{retention}{tag_suffix}" + + # Independent options (index/run_start/alias) keep their original order + # and run before the scoped combination. + result = other_parts + [combo] + if other_parts: + print( + " INFO: A scoped combination was detected alongside other remove options.\n" + " They will be deleted in this order:" + ) + for position, part in enumerate(other_parts, start=1): + print(f" {position}. {part}") + print(f" {len(other_parts) + 1}. {combo} (the scoped combination)") + print(combo_message) + return result + def _check_argument_warnings(self, arguments, outputs, outputfolderpaths, use_logs, generate_dashboard, no_autoupdate, offline_dependencies): """Checks for argument combinations that are valid but likely unintended and prints warnings""" no_outputs = not outputs and not outputfolderpaths @@ -275,8 +361,11 @@ def _parse_arguments(self): " • '-r run_start=2024-07-30 15:27:20.184407' -> remove specified run\n" " • '-r alias=some_alias,tag=prod'\n" " • '-r limit=10' -> keep only the 10 most recent runs\n" + " • '-r limit=10,tag=nightly' -> keep 10 newest 'nightly' runs, leave others\n" " • '-r age=10d' -> remove runs older than 10 days\n" " • '-r age=-10d' -> remove runs younger than 10 days\n" + " • '-r age=10d,tag=nightly' -> remove 'nightly' runs older than 10 days, leave others\n" + " • combine 'tag' with EITHER 'limit' OR 'age' (not both) to scope retention to those tag(s)\n" " • (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported\n" ), action="append", @@ -531,11 +620,13 @@ def _process_arguments(self, arguments): # handles the processing of --removeruns remove_runs = None if arguments.removeruns: - remove_runs = [] + raw_parts = [] for runs in arguments.removeruns: - parts = str(runs[0]).split(",") - for part in parts: - remove_runs.append(part) + for part in str(runs[0]).split(","): + part = part.strip() + if part: + raw_parts.append(part) + remove_runs = self._process_remove_runs(raw_parts) # handles the boolean handling of relevant arguments generate_dashboard = self._normalize_bool( diff --git a/robotframework_dashboard/database.py b/robotframework_dashboard/database.py index 928b50a..c134191 100644 --- a/robotframework_dashboard/database.py +++ b/robotframework_dashboard/database.py @@ -433,12 +433,12 @@ def remove_runs(self, remove_runs: list): console += self._remove_by_index(run, run_starts) elif "alias=" in run: console += self._remove_by_alias(run, run_starts, run_aliases) - elif "tag=" in run: - console += self._remove_by_tag(run, run_starts, run_tags) elif "limit=" in run: - console += self._remove_by_limit(run, run_starts) + console += self._remove_by_limit(run, run_starts, run_tags) elif "age=" in run: - console += self._remove_by_age(run, run_starts) + console += self._remove_by_age(run, run_starts, run_tags) + elif "tag=" in run: + console += self._remove_by_tag(run, run_starts, run_tags) else: print( f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions" @@ -513,16 +513,39 @@ def _remove_by_tag(self, run: str, run_starts: list, run_tags: list): console += f" WARNING: no runs were removed as no runs were found with tag: {tag}\n" return console - def _remove_by_limit(self, run: str, run_starts: list): + def _remove_by_limit(self, run: str, run_starts: list, run_tags: list = None): + """Keep the N newest runs, removing older ones. + + When tag filters are appended (e.g. 'limit=10;tag=nightly;tag=prod'), + the limit is scoped to runs matching any of those tags: the N newest + matching runs are kept, older matching runs are removed, and runs that + do not match any tag are left untouched. + """ console = "" - limit = int(run.replace("limit=", "")) - if limit >= len(run_starts): + parts = run.split(";") + limit = int(parts[0].replace("limit=", "")) + tag_filters = [ + part.replace("tag=", "") for part in parts[1:] if part.startswith("tag=") + ] + # run_starts are ordered oldest -> newest, so keeping the N newest means + # dropping the leading (oldest) candidates. + if tag_filters and run_tags is not None: + candidates = [ + index + for index, run_tag in enumerate(run_tags) + if any(tag in run_tag for tag in tag_filters) + ] + scope = f" with tag(s) {', '.join(tag_filters)}" + else: + candidates = list(range(len(run_starts))) + scope = "" + if limit >= len(candidates): print( - f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs ({len(run_starts)})" + f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs{scope} ({len(candidates)})" ) - console += f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs ({len(run_starts)})\n" + console += f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs{scope} ({len(candidates)})\n" return console - for index in range(len(run_starts) - limit): + for index in candidates[: len(candidates) - limit]: self._remove_run(run_starts[index]) print( f" Removed run from the database: index={index}, run_start={run_starts[index]}" @@ -530,16 +553,30 @@ def _remove_by_limit(self, run: str, run_starts: list): console += f" Removed run from the database: index={index}, run_start={run_starts[index]}\n" return console - def _remove_by_age(self, run_query: str, run_starts: list): + def _remove_by_age(self, run_query: str, run_starts: list, run_tags: list = None): + """Remove runs by age threshold. + + When tag filters are appended (e.g. 'age=10d;tag=nightly;tag=prod'), + only runs matching any of those tags are considered: matching runs that + fall within the age range are removed, runs without the tag(s) are left + untouched. + """ console = "" + parts = run_query.split(";") + tag_filters = [ + part.replace("tag=", "") for part in parts[1:] if part.startswith("tag=") + ] try: - clean_query = run_query.replace("age=", "") + clean_query = parts[0].replace("age=", "") mod, delta = self.parse_time_range(clean_query) except ValueError as e: return f" ERROR: {e}" cutoff = datetime.now(timezone.utc)-delta targets = [] - for r in run_starts: + for index, r in enumerate(run_starts): + if tag_filters and run_tags is not None: + if not any(tag in run_tags[index] for tag in tag_filters): + continue try: run_dt = datetime.fromisoformat(r) if run_dt.tzinfo is None: @@ -553,7 +590,8 @@ def _remove_by_age(self, run_query: str, run_starts: list): except ValueError as e: print(f" WARNING: Skipping invalid timestamp: '{r}' ({e})") if not targets: - console += f" WARNING: no runs were removed as no runs were within range {clean_query}" + scope = f" with tag(s) {', '.join(tag_filters)}" if tag_filters else "" + console += f" WARNING: no runs were removed as no runs{scope} were within range {clean_query}" return console for run_to_remove in targets: self._remove_run(run_to_remove) diff --git a/robotframework_dashboard/server.py b/robotframework_dashboard/server.py index 2be5222..4bdab15 100644 --- a/robotframework_dashboard/server.py +++ b/robotframework_dashboard/server.py @@ -113,7 +113,9 @@ "tags": ["tag1", "tag2", "tag3"], }, {"limit": 10}, + {"limit": 10, "tags": ["nightly"]}, {"age": "10d"}, + {"age": "10d", "tags": ["nightly"]}, {"age": "-10d"}, {"all": True}, ], @@ -147,11 +149,21 @@ "description": "Remove runs older than a threshold (e.g., '10d') or younger than a threshold (e.g., '-10d'). Supports (y)ear/(d)ay/(h)our/(m)inute/(s)econd.", "value": {"age": "10d"}, }, + "age_by_tag": { + "summary": "Remove tagged runs by age threshold", + "description": "When 'tags' is combined with 'age', the age threshold is scoped to runs matching any given tag: matching runs within the age range are removed, and runs without those tags are left untouched.", + "value": {"age": "10d", "tags": ["nightly"]}, + }, "limit": { "summary": "Remove all but the N most recent runs", "description": "Keep only the specified number of most recent runs, deleting the rest.", "value": {"limit": 10}, }, + "limit_by_tag": { + "summary": "Keep N most recent runs within a tag", + "description": "When 'tags' is combined with 'limit', the limit is scoped to runs matching any given tag: the N newest matching runs are kept, older matching runs are removed, and runs without those tags are left untouched.", + "value": {"limit": 10, "tags": ["nightly"]}, + }, "all": { "summary": "Remove all outputs", "description": "Delete all runs currently stored in the database. This is irreversible.", @@ -601,6 +613,17 @@ async def remove_outputs_from_database( Can be either indexes or run_starts that are known in the database """ console = "no console output" + # 'tags' may be scoped to EITHER 'limit' OR 'age', not both at once. + if ( + remove_output.tags != None + and remove_output.limit != None + and remove_output.age != None + ): + message = ( + "ERROR: Cannot combine 'limit' and 'age' with 'tags' at the same time. " + "Provide either 'limit'+'tags' or 'age'+'tags', not both." + ) + return {"success": "0", "message": message, "console": message} try: # Because the argparser makes use of the format: [[outputtoremove1], [outputtoremove2]] # We have to create a list of lists with 1 item to match the handling of the API @@ -621,13 +644,25 @@ async def remove_outputs_from_database( if remove_output.aliases != None: for run in remove_output.aliases: remove_runs.append(f"alias={run}") - if remove_output.tags != None: + # When tags are combined with limit and/or age, scope that + # retention to the tagged runs (keep/remove only matching + # runs, leave others alone) instead of removing all tagged + # runs outright. + scope_tags = remove_output.tags != None and ( + remove_output.limit != None or remove_output.age != None + ) + tag_suffix = ( + "".join(f";tag={tag}" for tag in remove_output.tags) + if scope_tags + else "" + ) + if remove_output.tags != None and not scope_tags: for run in remove_output.tags: remove_runs.append(f"tag={run}") if remove_output.age != None: - remove_runs.append(f"age={remove_output.age}") + remove_runs.append(f"age={remove_output.age}{tag_suffix}") if remove_output.limit != None: - remove_runs.append(f"limit={remove_output.limit}") + remove_runs.append(f"limit={remove_output.limit}{tag_suffix}") paths_before = self.robotdashboard.get_run_paths() console = self.robotdashboard.remove_outputs(remove_runs) paths_after = self.robotdashboard.get_run_paths() diff --git a/robotframework_dashboard/templates/admin.html b/robotframework_dashboard/templates/admin.html index 8595971..63a5cdb 100644 --- a/robotframework_dashboard/templates/admin.html +++ b/robotframework_dashboard/templates/admin.html @@ -238,7 +238,8 @@

Remove output.xml(s) From Database

+ or -10d (delete younger than 10 days). (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported. + Combine with the tag field above to only delete tagged runs in that range (other runs untouched).
@@ -248,7 +249,7 @@

Remove output.xml(s) From Database

+ runs to keep). Combine with the tag field above to keep the N newest runs per tag (other runs untouched).
diff --git a/tests/python/test_arguments.py b/tests/python/test_arguments.py index c0a677a..45162a9 100644 --- a/tests/python/test_arguments.py +++ b/tests/python/test_arguments.py @@ -198,6 +198,72 @@ def test_process_arguments_with_removeruns(): assert result.remove_runs == ["index=0", "index=1"] +# --- _process_remove_runs: scoped retention combinations --- + +def test_remove_runs_passthrough_without_combination(): + # No tags -> nothing combined + assert ArgumentParser()._process_remove_runs(["index=0", "limit=10"]) == [ + "index=0", + "limit=10", + ] + + +def test_remove_runs_tag_only_passthrough(): + # tags without a limit/age partner stay independent (full wipe) + assert ArgumentParser()._process_remove_runs(["tag=dev"]) == ["tag=dev"] + + +def test_remove_runs_limit_and_tag_combined(): + assert ArgumentParser()._process_remove_runs(["limit=10", "tag=dev"]) == [ + "limit=10;tag=dev" + ] + + +def test_remove_runs_age_and_tag_combined(): + assert ArgumentParser()._process_remove_runs(["age=10d", "tag=dev"]) == [ + "age=10d;tag=dev" + ] + + +def test_remove_runs_limit_and_multiple_tags_combined(): + assert ArgumentParser()._process_remove_runs( + ["limit=10", "tag=dev", "tag=prod"] + ) == ["limit=10;tag=dev;tag=prod"] + + +def test_remove_runs_combination_with_other_options_keeps_order(): + # independent ops first (in order), scoped combination last + assert ArgumentParser()._process_remove_runs( + ["index=0", "limit=10", "tag=dev"] + ) == ["index=0", "limit=10;tag=dev"] + + +def test_remove_runs_all_three_errors(capsys): + with pytest.raises(SystemExit) as exc: + ArgumentParser()._process_remove_runs(["limit=10", "age=10d", "tag=dev"]) + assert exc.value.code == 3 + assert "Cannot combine 'limit' and 'age'" in capsys.readouterr().out + + +def test_remove_runs_multiple_limits_with_tag_errors(capsys): + with pytest.raises(SystemExit) as exc: + ArgumentParser()._process_remove_runs(["limit=10", "limit=5", "tag=dev"]) + assert exc.value.code == 4 + assert "Only one 'limit'" in capsys.readouterr().out + + +def test_remove_runs_emits_info_message(capsys): + ArgumentParser()._process_remove_runs(["limit=10", "tag=dev"]) + out = capsys.readouterr().out + assert "INFO: Combining 'limit' with 'tag(s)'" in out + + +def test_remove_runs_combination_via_process_arguments(): + args = _make_namespace(removeruns=[["limit=10,tag=dev"]]) + result = ArgumentParser()._process_arguments(args) + assert result.remove_runs == ["limit=10;tag=dev"] + + def test_process_arguments_with_messageconfig(tmp_path): msg_file = tmp_path / "messages.txt" msg_file.write_text("Template: ${name}\nLine 2\n") diff --git a/tests/python/test_database.py b/tests/python/test_database.py index 25c75a3..927a8bc 100644 --- a/tests/python/test_database.py +++ b/tests/python/test_database.py @@ -201,6 +201,130 @@ def test_remove_by_limit_higher_than_count_is_noop(populated_db): assert len(populated_db.get_data()["runs"]) == 1 +# --- remove_runs by limit scoped to tag(s) (issue #309) --- + +def _insert_run(db, xml, tags): + """Helper: insert a run from the given XML with the given tags.""" + processor = OutputProcessor(xml) + processor.get_run_start() + data = processor.get_output_data() + db.insert_output_data(data, tags, None, xml, None) + + +def _tags_of(runs): + return [run["tags"] for run in runs] + + +def _run_starts(db): + return [run["run_start"] for run in db.get_data()["runs"]] + + +def test_remove_by_limit_with_single_tag_keeps_newest_matching(db): + db.open_database() + # oldest -> newest; three "nightly" runs + one unrelated "release" run + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002257.xml", ["release"]) + starts_before = _run_starts(db) # ordered oldest -> newest + db.remove_runs(["limit=2;tag=nightly"]) + starts_after = _run_starts(db) + # oldest nightly removed; 2 newest nightly + release remain + assert len(starts_after) == 3 + assert starts_before[0] not in starts_after # oldest nightly removed + assert starts_before[3] in starts_after # release untouched + db.close_database() + + +def test_remove_by_limit_with_multiple_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["alpha"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["beta"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["alpha"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002257.xml", ["gamma"]) + starts_before = _run_starts(db) + # candidates = union of alpha+beta = 3 oldest runs; keep 2 newest of those + db.remove_runs(["limit=2;tag=alpha;tag=beta"]) + starts_after = _run_starts(db) + assert len(starts_after) == 3 + assert starts_before[0] not in starts_after # oldest alpha removed + assert starts_before[3] in starts_after # gamma untouched + db.close_database() + + +def test_remove_by_limit_with_tag_higher_than_count_is_noop(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["other"]) + console = db.remove_runs(["limit=5;tag=nightly"]) + assert len(db.get_data()["runs"]) == 3 + assert "WARNING" in console + db.close_database() + + +def test_remove_by_limit_only_ignores_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) + starts_before = _run_starts(db) + # no tag scope -> global limit, keep 1 newest regardless of tag + db.remove_runs(["limit=1"]) + starts_after = _run_starts(db) + assert starts_after == [starts_before[-1]] # only the newest remains + db.close_database() + + +# --- remove_runs by age scoped to tag(s) (issue #309 follow-up) --- +# Sample XMLs are dated 2025-03 -> always "older" than the test run date. + +def test_remove_by_age_with_single_tag_only_removes_matching(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) + starts_before = _run_starts(db) + # remove only 'nightly' runs older than 10 days; release untouched + db.remove_runs(["age=10d;tag=nightly"]) + starts_after = _run_starts(db) + assert len(starts_after) == 1 + assert starts_after == [starts_before[1]] # the release run remains + db.close_database() + + +def test_remove_by_age_with_multiple_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["alpha"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["beta"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["gamma"]) + starts_before = _run_starts(db) + db.remove_runs(["age=10d;tag=alpha;tag=beta"]) + starts_after = _run_starts(db) + assert starts_after == [starts_before[2]] # only gamma remains + db.close_database() + + +def test_remove_by_age_with_tag_no_match_is_noop(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) + console = db.remove_runs(["age=10d;tag=nonexistent"]) + assert len(db.get_data()["runs"]) == 2 + assert "WARNING" in console + db.close_database() + + +def test_remove_by_age_only_ignores_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) + # no tag scope -> all runs older than 10 days removed regardless of tag + db.remove_runs(["age=10d"]) + assert len(db.get_data()["runs"]) == 0 + db.close_database() + + # --- list_runs --- def test_list_runs_empty_prints_warning(db, capsys): diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 96b9ad9..8aa39d0 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -434,6 +434,53 @@ def test_remove_outputs_by_limit(): assert response.json()["success"] == "1" +def test_remove_outputs_by_limit_and_tags_builds_scoped_query(): + """limit + tags -> single scoped 'limit=N;tag=...' query, no standalone tag removals.""" + server = _make_server() + client = _client(server) + response = client.request( + "DELETE", + "/remove-outputs", + json={"limit": 5, "tags": ["nightly", "prod"]}, + ) + assert response.status_code == 200 + args = server.robotdashboard.remove_outputs.call_args[0][0] + assert args == ["limit=5;tag=nightly;tag=prod"] + # tags must not be removed independently when scoped to the limit + assert not any(r == "tag=nightly" for r in args) + + +def test_remove_outputs_by_age_and_tags_builds_scoped_query(): + """age + tags -> single scoped 'age=...;tag=...' query, no standalone tag removals.""" + server = _make_server() + client = _client(server) + response = client.request( + "DELETE", + "/remove-outputs", + json={"age": "10d", "tags": ["nightly", "prod"]}, + ) + assert response.status_code == 200 + args = server.robotdashboard.remove_outputs.call_args[0][0] + assert args == ["age=10d;tag=nightly;tag=prod"] + assert not any(r == "tag=nightly" for r in args) + + +def test_remove_outputs_limit_age_tags_all_three_errors(): + """limit + age + tags together is rejected before any removal.""" + server = _make_server() + client = _client(server) + response = client.request( + "DELETE", + "/remove-outputs", + json={"limit": 5, "age": "10d", "tags": ["nightly"]}, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"] == "0" + assert "Cannot combine 'limit' and 'age'" in body["message"] + server.robotdashboard.remove_outputs.assert_not_called() + + def test_remove_outputs_all_flag(): server = _make_server() server.robotdashboard.get_runs.return_value = (