diff --git a/.github/skills/server-api.md b/.github/skills/server-api.md index 3630966c..31ddf372 100644 --- a/.github/skills/server-api.md +++ b/.github/skills/server-api.md @@ -39,11 +39,11 @@ Served with **uvicorn** via `fastapi_offline.FastAPIOffline`. | `POST` | `/refresh-dashboard` | Basic (if configured) | Manually triggers `robotdashboard.create_dashboard()` without adding data | | `GET` | `/get-outputs` | None | Returns list of `{run_start, name, alias, tags}` for all stored runs | | `POST` | `/add-outputs` | Basic (if configured) | Add output(s) from a path, raw XML string, or folder. See body fields below. | -| `POST` | `/add-output-file` | Basic (if configured) | Multipart upload of `output.xml` (or `.gz`/`.gzip`). Form fields: `tags` (colon-separated), `version`. | +| `POST` | `/add-output-file` | Basic (if configured) | Multipart upload of `output.xml` (or `.gz`/`.gzip`). Form fields: `tags` (colon-separated), `version`, `custom_filters`, `log_url`. | | `DELETE` | `/remove-outputs` | Basic (if configured) | Remove runs by various selectors. See body fields below. | | `GET` | `/get-logs` | None | Lists filenames in `robot_logs/` | | `POST` | `/add-log` | Basic (if configured) | Saves HTML log content to `robot_logs/` and links it to the matching run in the DB | -| `POST` | `/add-log-file` | Basic (if configured) | Same as `/add-log` but via multipart file upload (supports `.gz`/`.gzip`) | +| `POST` | `/add-log-file` | Basic (if configured) | Same as `/add-log` but via multipart file upload (supports `.gz`/`.gzip`). Filenames containing `report` are saved as-is with no DB matching — see Report Handling below. | | `DELETE` | `/remove-log` | Basic (if configured) | Removes one log by `log_name`, or all logs with `all: True` | | `GET` | `/log` | None | Serves a log HTML file by `?path=` query param; stores parent dir for subsequent resource requests | | `GET` | `/{full_path:path}` | None | Catch-all: serves static resources (screenshots, etc.) relative to the last served log's directory. Path-traversal protected. | @@ -63,6 +63,7 @@ Exactly one input source must be provided (mutually exclusive): | `output_tags` | `List[str]` | Optional tags to attach to all added runs | | `output_alias` | `str` | Optional alias override | | `output_version` | `str` | Optional project version string | +| `output_log_url` | `str` | Optional externally-hosted log URL, mirrors CLI `--logurl`. Requires a `{run_alias}` placeholder when `output_folder_path` is used (potentially multiple runs); rejected without one. `/add-output-file`'s multipart equivalent is the unprefixed `log_url` form field (no placeholder requirement there since it always processes a single file). | ### `DELETE /remove-outputs` Any combination of the following: @@ -112,6 +113,15 @@ The suffix `XYZ` must match between the output file and its corresponding log. T --- +## Report Handling (`/add-log-file`) + +Reports are not tracked in the database — Robot Framework's own `log.html` already links to `report.html` (top-right corner), so the dashboard just needs the report saved next to its log with a matching name (`log_XYZ.html` ↔ `report_XYZ.html`). + +`/add-log-file` detects a `report` substring in the uploaded filename and takes a different path than for `log` files: +- The file is saved to `robot_logs/` as-is; `update_output_path()` (DB matching) is **not** called — a report filename would never match a stored output/log path anyway. +- It checks whether a `log` file exists with the same name (`report` → `log`) in `robot_logs/`. If found: `SUCCESS` with a console note that the report is reachable from its log. If not found: still `SUCCESS`, but the console carries a `WARNING` that the report was saved but has no matching log yet (upload order matters — upload the log first, or the report before it just produces a harmless warning). +- This intentionally returns `success: "1"` either way; before this behavior, a `report*.html` upload always returned `success: "0"` with a misleading DB-matching error even though the file was saved correctly (#308). + ## Server State The `ApiServer` instance holds: diff --git a/docs/dashboard-server.md b/docs/dashboard-server.md index b284ecc9..267dd081 100644 --- a/docs/dashboard-server.md +++ b/docs/dashboard-server.md @@ -152,6 +152,8 @@ The admin page supports four methods for adding test results: | **By Folder Path** | Provide a folder path; the server recursively scans for `*output*.xml` files. Supports run tags, version label, and custom filters. | | **By File Upload** | Upload an `output.xml` file directly. Supports run tags, version label, and custom filters. Gzip-compressed files (`.gz`/`.gzip`) are automatically decompressed. | +> **Tip:** Every method above also accepts an optional log URL (`output_log_url` in `/add-outputs`, `log_url` in `/add-output-file`) mirroring the CLI's [`--logurl`](/basic-command-line-interface-cli.md) flag. Use this when the log is already hosted elsewhere (e.g. a CI artifact or cloud storage URL) instead of uploading it to this server via `/add-log` / `/add-log-file`. Include a `{run_alias}` placeholder in the URL when a single request may add more than one run (e.g. `output_folder_path`), otherwise every run added by that request would be stored with the same URL. + ### Removing Outputs | Method | Description | @@ -176,6 +178,8 @@ The admin page supports four methods for adding test results: | **Remove Log by Name** | Remove a specific log file (e.g., `log-20250219-172535.html`). | | **Remove All Logs** | Irreversibly deletes all uploaded log files. | +> **Note:** Reports are not tracked in the database — they're reached through the link Robot Framework builds into the top-right corner of `log.html`, so `/add-log` and `/add-log-file` never need to associate a report with a run directly. Uploading a filename containing `report` (e.g. `report_20250219-172535.html`) via `/add-log-file` is still supported: the file is saved to `robot_logs/` as-is and no database matching is attempted. The response reports `SUCCESS` either way, with the console message noting whether a matching `log` file (same name, `report` replaced by `log`) was found in `robot_logs/` — if not, it's just a warning that the report may not yet be reachable from a log, not an error. See [Log Linking](/log-linking.md#accessing-reports) for naming and placement requirements. + ### Database & Log Tables The admin page displays two tables: diff --git a/docs/log-linking.md b/docs/log-linking.md index 33530390..47b17aae 100644 --- a/docs/log-linking.md +++ b/docs/log-linking.md @@ -151,3 +151,5 @@ Robot Framework `report.html` files can also be accessed through the log file: | --------------------------------- | ---------------------------------- | | `log_nightly.html` | `report_nightly.html` | | `my_log_123.html` | `my_report_123.html` | + +When uploading through the [dashboard server](/dashboard-server.md), upload the report the same way as the log: `POST /add-log-file` with the report file. Reports are not stored in the database, so no output-matching is attempted for a filename containing `report` — the file is just saved to `robot_logs/`. Upload the report after its matching log so the response can confirm the log was found; uploading it first only produces a harmless warning that no matching log exists yet. diff --git a/example/server/interact.http b/example/server/interact.http index df2ca476..1cdfa45e 100644 --- a/example/server/interact.http +++ b/example/server/interact.http @@ -38,6 +38,8 @@ content-type: application/json ### ### add an output by folder path — all *output*.xml files are picked up recursively (auth required) +### output_log_url mirrors the CLI '--logurl' flag; the '{run_alias}' placeholder is required here +### because a folder can contain multiple outputs, so every run needs its own URL POST http://{{host}}:{{port}}/add-outputs HTTP/1.1 Authorization: Basic {{base64credentials}} content-type: application/json @@ -45,7 +47,8 @@ content-type: application/json { "output_folder_path": "C:\\users\\docs\\prod-outputs", "output_tags": ["production-run"], - "output_version": "v1.2.3" + "output_version": "v1.2.3", + "output_log_url": "https://ci.example.com/build42/log_{run_alias}.html" } ### diff --git a/example/server/interact.py b/example/server/interact.py index 919fa87f..f6431184 100644 --- a/example/server/interact.py +++ b/example/server/interact.py @@ -45,10 +45,13 @@ print() # add an output by folder path — all *output*.xml files are picked up recursively +# output_log_url mirrors the CLI '--logurl' flag; the '{run_alias}' placeholder is required here +# because a folder can contain multiple outputs, so every run needs its own URL body = { "output_folder_path": str(OUTPUT_FOLDER_PATH), "output_tags": ["production-run"], "output_version": "v1.2.3", + "output_log_url": "https://ci.example.com/build42/log_{run_alias}.html", } response = requests.post(f"{BASE_URL}/add-outputs", json=body, auth=AUTH) print("add-outputs (by folder):", response.json()) @@ -60,7 +63,7 @@ from gzip import compress compressed = compress(f.read()) files = {"file": (f"{output_path.name}.gz", compressed, "application/gzip")} -form = {"tags": "tag1:tag2", "version": "v1.2.3", "custom_filters": "env=prod"} +form = {"tags": "tag1:tag2", "version": "v1.2.3", "custom_filters": "env=prod", "log_url": "https://ci.example.com/build42/log.html"} response = requests.post(f"{BASE_URL}/add-output-file", files=files, data=form, auth=AUTH) print("add-output-file:", response.json()) print() diff --git a/example/server/interact.robot b/example/server/interact.robot index 83cc00dd..8ebae89b 100644 --- a/example/server/interact.robot +++ b/example/server/interact.robot @@ -43,11 +43,14 @@ Add Output By Path With All Options Add Output By Folder Path [Documentation] Add all *output*.xml files found recursively in a folder (auth required) + ... output_log_url mirrors the CLI '--logurl' flag; the '{run_alias}' placeholder is required + ... here because a folder can contain multiple outputs, so every run needs its own URL VAR @{tags} production-run VAR &{body} ... output_folder_path=C:\\users\\docs\\prod-outputs ... output_tags=${tags} ... output_version=v1.2.3 + ... output_log_url=https://ci.example.com/build42/log_{run_alias}.html ${response} POST url=${URL}/add-outputs json=${body} auth=${AUTH} Log ${response.json()} diff --git a/robotframework_dashboard/server.py b/robotframework_dashboard/server.py index 4a8eb21f..fe4f438b 100644 --- a/robotframework_dashboard/server.py +++ b/robotframework_dashboard/server.py @@ -94,6 +94,14 @@ "output_version": "v1.2", }, }, + "output_with_log_url": { + "summary": "Add an output with an externally hosted log URL", + "description": "Provide an output.xml path along with 'output_log_url' to store a link to a log already hosted elsewhere (e.g., a CI artifact or cloud storage URL) instead of a local log file. Use the '{run_alias}' placeholder when processing multiple outputs (e.g. via 'output_folder_path') so each run gets its own URL.", + "value": { + "output_path": "C:\\users\\docs\\output.xml", + "output_log_url": "https://ci.example.com/build42/log.html", + }, + }, }, } } @@ -250,6 +258,7 @@ class AddOutput(BaseModel): output_alias: Optional[str] = None output_version: Optional[str] = None output_custom_filters: Optional[str] = None + output_log_url: Optional[str] = None model_config = add_output_model_config @@ -465,6 +474,10 @@ async def add_output_to_database( 1. output_path: str valid path to output.xml (+ optional 'output_tags: List[str]' or optional 'output_version: str' version) 2. output_data: str output.xml content (+ optional 'output_tags: List[str]', optional 'output_alias: str` or optional 'output_version: str' version) 3. output_folder_path: str valid path to folder (subfolders are also searched) that contain *output*.xml (+ optional 'output_tags: List[str]' or optional 'output_version: str' version) + 'output_log_url' is optional on all 3 combinations and mirrors the CLI '--logurl' flag: it stores a link to an + externally hosted log instead of the local output path. Use the '{run_alias}' placeholder when the request may + process more than one output (e.g. via 'output_folder_path'), otherwise every run would be stored with the + same URL. """ input = "provided input, overwritten on runtime" console = "no console output" @@ -487,6 +500,17 @@ async def add_output_to_database( raise Exception( "Please only provide output_path, output_data or output_folder_path, not more than 1 type at the same time!" ) + if ( + add_output.output_log_url + and "{run_alias}" not in add_output.output_log_url + and add_output.output_folder_path != None + ): + input = "your input" + raise Exception( + "'output_log_url' was provided without a '{run_alias}' placeholder while processing an output_folder_path " + "(potentially multiple outputs). Either add '{run_alias}' to the URL template or provide a single output " + "via 'output_path' or 'output_data'." + ) output_tags = [] if add_output.output_tags: output_tags = add_output.output_tags @@ -494,6 +518,7 @@ async def add_output_to_database( self.robotdashboard.project_version = add_output.output_version else: self.robotdashboard.project_version = None + self.robotdashboard.log_url = add_output.output_log_url or None self.robotdashboard.custom_filters = add_output.output_custom_filters if add_output.output_path != None: input = add_output.output_path @@ -547,11 +572,15 @@ async def add_output_file( tags: str = Form(default=""), version: str = Form(default=""), custom_filters: str = Form(default=""), + log_url: str = Form(default=""), username: str = Depends(authenticate), ) -> ResponseMessage: """Add output file to database endpoint function The tags parameter should be provided as colon-separated values (e.g., 'tag1:tag2:tag3') The version parameter is an optional string to label the run (e.g., software version) + The log_url parameter is optional and mirrors the CLI '--logurl' flag: it stores a link to an externally + hosted log instead of the local output path. Since this endpoint always processes a single output file, + the '{run_alias}' placeholder is optional here. """ console = "no console output" try: @@ -579,6 +608,7 @@ async def add_output_file( self.robotdashboard.project_version = None self.robotdashboard.custom_filters = custom_filters + self.robotdashboard.log_url = log_url or None outputs = [[output_path, output_tags]] console = self.robotdashboard.process_outputs( @@ -740,6 +770,11 @@ async def add_log_file( ) -> ResponseMessage: """Add log file to server endpoint function The log file name should match the output.xml alias (e.g., 'log-alias.html' for 'output-alias.xml') + + Report files (filename containing 'report') are handled differently: reports are not tracked in the + database, so no output-matching is attempted. The file is saved as-is and this endpoint only checks + whether a corresponding 'log' file already exists next to it (so it can be reached via the link Robot + Framework builds into log.html) and warns, rather than errors, if it does not. """ console = "" try: @@ -759,11 +794,23 @@ async def add_log_file( with open(log_path, "wb") as buffer: buffer.write(file_bytes) - console += self.robotdashboard.update_output_path(log_path) - if "ERROR" in console: - raise Exception( - "A problem occurred while adding the log file, check the console message!" - ) + log_name = Path(log_path).name + is_report = "report" in log_name + if is_report: + expected_log_name = log_name.replace("report", "log") + if exists(join(self.log_dir, expected_log_name)): + console += f"SUCCESS: matching log file '{expected_log_name}' found, the report is reachable from it.\n" + else: + console += ( + f"WARNING: no matching log file '{expected_log_name}' was found in '{self.log_dir}'. " + "The report has been saved but may not be reachable until a matching log is uploaded.\n" + ) + else: + console += self.robotdashboard.update_output_path(log_path) + if "ERROR" in console: + raise Exception( + "A problem occurred while adding the log file, check the console message!" + ) console += "======================================================================================\n" console += f"Added {file.filename} to the folder {self.log_dir}\n" console += "======================================================================================\n" @@ -782,7 +829,11 @@ async def add_log_file( return response response = { "success": "1", - "message": f"SUCCESS: the log file has been placed and the database was updated", + "message": ( + "SUCCESS: the report file has been placed" + if is_report + else "SUCCESS: the log file has been placed and the database was updated" + ), "console": console, } return response diff --git a/tests/python/test_server.py b/tests/python/test_server.py index a109f8c5..b8cc23db 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -251,6 +251,25 @@ def test_add_outputs_no_version_clears_project_version(): assert server.robotdashboard.project_version is None +def test_add_outputs_by_path_with_log_url(): + server = _make_server() + client = _client(server) + payload = {"output_path": str(SAMPLE_XML), "output_log_url": "https://ci.example.com/log.html"} + response = client.post("/add-outputs", json=payload) + assert response.status_code == 200 + assert response.json()["success"] == "1" + assert server.robotdashboard.log_url == "https://ci.example.com/log.html" + + +def test_add_outputs_no_log_url_clears_log_url(): + server = _make_server() + server.robotdashboard.log_url = "https://old.example.com/log.html" + client = _client(server) + payload = {"output_path": str(SAMPLE_XML)} + client.post("/add-outputs", json=payload) + assert server.robotdashboard.log_url is None + + # --------------------------------------------------------------------------- # POST /add-outputs — by output_folder_path # --------------------------------------------------------------------------- @@ -265,6 +284,31 @@ def test_add_outputs_by_folder(): server.robotdashboard.process_outputs.assert_called_once() +def test_add_outputs_by_folder_with_log_url_placeholder(): + server = _make_server() + client = _client(server) + payload = { + "output_folder_path": str(OUTPUTS_DIR), + "output_log_url": "https://ci.example.com/{run_alias}/log.html", + } + response = client.post("/add-outputs", json=payload) + assert response.status_code == 200 + assert response.json()["success"] == "1" + + +def test_add_outputs_by_folder_with_log_url_without_placeholder_rejected(): + server = _make_server() + client = _client(server) + payload = { + "output_folder_path": str(OUTPUTS_DIR), + "output_log_url": "https://ci.example.com/log.html", + } + response = client.post("/add-outputs", json=payload) + assert response.status_code == 200 + assert response.json()["success"] == "0" + server.robotdashboard.process_outputs.assert_not_called() + + # --------------------------------------------------------------------------- # POST /add-outputs — by output_data # --------------------------------------------------------------------------- @@ -386,6 +430,35 @@ def test_add_output_file_with_tags(tmp_path, monkeypatch): assert server.robotdashboard.project_version == "2.0" +def test_add_output_file_with_log_url(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + server = _make_server() + client = _client(server) + xml_bytes = SAMPLE_XML.read_bytes() + response = client.post( + "/add-output-file", + files={"file": ("output-test.xml", xml_bytes, "application/xml")}, + data={"log_url": "https://ci.example.com/log.html"}, + ) + assert response.status_code == 200 + assert response.json()["success"] == "1" + assert server.robotdashboard.log_url == "https://ci.example.com/log.html" + + +def test_add_output_file_no_log_url_clears_log_url(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + server = _make_server() + server.robotdashboard.log_url = "https://old.example.com/log.html" + client = _client(server) + xml_bytes = SAMPLE_XML.read_bytes() + response = client.post( + "/add-output-file", + files={"file": ("output-test.xml", xml_bytes, "application/xml")}, + ) + assert response.status_code == 200 + assert server.robotdashboard.log_url is None + + # --------------------------------------------------------------------------- # DELETE /remove-outputs # --------------------------------------------------------------------------- @@ -637,6 +710,64 @@ def test_add_log_file_update_path_error(tmp_path, monkeypatch): assert response.json()["success"] == "0" +# --------------------------------------------------------------------------- +# POST /add-log-file — report files (#308: saved without DB matching, warn +# instead of error when no matching log is found) +# --------------------------------------------------------------------------- + +def test_add_log_file_report_without_matching_log_warns_but_succeeds(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + server = _make_server() + server.log_dir = str(tmp_path / "robot_logs") + client = _client(server) + response = client.post( + "/add-log-file", + files={"file": ("report-abc.html", b"report", "text/html")}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] == "1" + assert "WARNING" in data["console"] + assert (tmp_path / "robot_logs" / "report-abc.html").exists() + server.robotdashboard.update_output_path.assert_not_called() + + +def test_add_log_file_report_with_matching_log_succeeds_without_warning(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + server = _make_server() + log_dir = tmp_path / "robot_logs" + log_dir.mkdir() + (log_dir / "report-abc.html".replace("report", "log")).write_text("log") + server.log_dir = str(log_dir) + client = _client(server) + response = client.post( + "/add-log-file", + files={"file": ("report-abc.html", b"report", "text/html")}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] == "1" + assert "WARNING" not in data["console"] + server.robotdashboard.update_output_path.assert_not_called() + + +def test_add_log_file_report_gzipped(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + server = _make_server() + server.log_dir = str(tmp_path / "robot_logs") + client = _client(server) + gz_data = gzip.compress(b"report content") + response = client.post( + "/add-log-file", + files={"file": ("report-abc.html.gz", gz_data, "application/gzip")}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] == "1" + assert (tmp_path / "robot_logs" / "report-abc.html").exists() + server.robotdashboard.update_output_path.assert_not_called() + + # --------------------------------------------------------------------------- # DELETE /remove-log # ---------------------------------------------------------------------------