diff --git a/README.md b/README.md index 2f92c91b..383aeb9c 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,26 @@ avr run logs # full log dump, paginated avr job logs --follow # tail one job in real time ``` +**Read a job's recorded tests** + +```sh +avr tests # names, outcomes, timings and failure messages +avr tests --json '*' # results with reporting state and pagination +avr tests --limit 500 # follow short pages up to the requested count +avr tests --cursor '' # continue with pagination.next_cursor +``` + +Use the Avrea job ID (`job-` or `awj-`) from `avr job list`; the repository is +resolved from that job. `avr tests list ` is equivalent. `--json FIELDS` and `--jq` also +work. Each invocation reads at most 1,000 occurrences and 20 result pages, +preserving repeated tests and returning a cursor when more results remain. +Missing or expired reports are distinct from a retained report containing zero +tests. A report marked complete does not establish that every expected test ran. + +This requires an API deployment supporting job test results and access to the +repository's test evidence. Only Avrea job IDs are supported. The command does +not rerun tests or change collection. + **Trigger a workflow_dispatch** ```sh diff --git a/avrea_cli/commands/tests.py b/avrea_cli/commands/tests.py new file mode 100644 index 00000000..9e5863cb --- /dev/null +++ b/avrea_cli/commands/tests.py @@ -0,0 +1,236 @@ +"""Read the retained test results of a job.""" + +from avrea_cli.api_client import ApiClient +from avrea_cli.click_ext import GhGroup +from avrea_cli.config import CliConfig +from avrea_cli.display import escape_control_characters +from avrea_cli.helpers import ensure_authenticated +from avrea_cli.helpers import ensure_ctx +from avrea_cli.helpers import get_org_id +from avrea_cli.helpers import handle_http_error +from avrea_cli.helpers import validate_cursor +from avrea_cli.json_output import emit_json_record +from avrea_cli.json_output import handle_json_meta +from avrea_cli.json_output import json_options +from avrea_cli.json_output import make_schema +from avrea_cli.json_output import split_fields +from avrea_cli.output import output_list +from avrea_cli.repo_context import resolve_repo +from typing import Any +import click +import httpx +import re +import shlex + +_SNAPSHOT_FIELDS = ( + "job_id", + "execution_id", + "status", + "reporting_state", + "reason", + "commit_sha", + "observed_at", + "total_count", +) +_FIELDS = make_schema("organization_id", "repository_id", *_SNAPSHOT_FIELDS, "data", "pagination") +_MAX_PAGES = 20 +_JOB_ID = re.compile(r"(job|awj)-[0-9a-f]{32}") +_INVALID_NEXT_CURSOR = "The API returned an invalid next cursor." + + +class JobTestsGroup(GhGroup): + """Accept the documented job shorthand without swallowing unknown verbs.""" + + def resolve_command( + self, ctx: click.Context, args: list[str] + ) -> tuple[str | None, click.Command | None, list[str]]: + if args and args[0].startswith(("job-", "awj-")): + args = ["list", *args] + return super().resolve_command(ctx, args) + + +def _job_id(_ctx: click.Context, param: click.Parameter, value: str) -> str: + if not _JOB_ID.fullmatch(value): + raise click.BadParameter( + "use the Avrea job ID from 'avr job list' (job- or awj- followed by 32 lowercase hex characters)", + param=param, + ) + return value + + +def _get( + client: ApiClient, path: str, action: str, not_found_hint: str, params: dict[str, Any] | None = None +) -> dict[str, Any]: + try: + response = client.public_get(path, params=params) + except httpx.HTTPStatusError as exc: + handle_http_error(exc, action, hints={404: not_found_hint}) + return response + + +def _job_repository(client: ApiClient, organization: str, job: str) -> str: + response = _get( + client, + f"/orgs/{organization}/jobs/{job}", + "read the job", + "Check the job ID, or pass --org with the job's organization.", + ) + data = response.get("data", response) + repository = data.get("repository_id") if isinstance(data, dict) else None + if not isinstance(repository, str) or not re.fullmatch(r"rep-[0-9a-f]{32}", repository): + raise click.ClickException("The job has no available repository. Check the job and selected organization.") + if data.get("job_id") != job: + raise click.ClickException("The API returned a different job. Retry with the exact Avrea job ID.") + return repository + + +def _results(client: ApiClient, path: str, job: str, limit: int, cursor: str | None) -> dict[str, Any]: + """Follow short pages within a row and request budget, retaining the resume cursor.""" + rows: list[dict[str, Any]] = [] + snapshot: dict[str, Any] | None = None + seen = {cursor} if cursor else set() + for _ in range(_MAX_PAGES): + page_limit = limit - len(rows) + params: dict[str, Any] = {"limit": page_limit} + if cursor is not None: + params["cursor"] = cursor + page = _get( + client, + path, + "read job test results", + # A new runtime invalidates old cursors, and the route also answers 404 + # when Repository Tests are off or the job is outside the repository. + "If you passed --cursor, restart without it. Otherwise check that Repository" + " Tests are enabled and that the job belongs to the repository.", + params, + ) + data = page.get("data") + pagination = page.get("pagination") + if page.get("job_id") != job or not isinstance(data, list) or not isinstance(pagination, dict): + raise click.ClickException("The API returned invalid job test results.") + if len(data) > page_limit or not all(isinstance(row, dict) for row in data): + raise click.ClickException("The API returned an invalid test result page.") + if snapshot is None: + snapshot = page + elif any(page.get(key) != snapshot.get(key) for key in _SNAPSHOT_FIELDS): + raise click.ClickException("Job results changed while reading pages. Retry without --cursor.") + cursor = pagination.get("next_cursor") + if cursor is not None: + if not isinstance(cursor, str): + raise click.ClickException(_INVALID_NEXT_CURSOR) + try: + validate_cursor(cursor, max_length=1024) + except click.BadParameter: + # The server's cursor is not user input, so it gets no usage error. + raise click.ClickException(_INVALID_NEXT_CURSOR) from None + if cursor in seen: + raise click.ClickException("The API repeated a test-results cursor; stopping pagination.") + seen.add(cursor) + rows.extend(data) + if cursor is None or len(rows) == limit: + break + assert snapshot is not None + return {**snapshot, "data": rows, "pagination": {"next_cursor": cursor}} + + +def _text(value: Any) -> str: + return "unknown" if value is None else escape_control_characters(str(value)) + + +def _show(response: dict[str, Any]) -> None: + click.echo(f"Job: {_text(response['job_id'])}") + click.echo(f"Organization: {_text(response['organization_id'])} Repository: {_text(response['repository_id'])}") + click.echo(f"Execution: {_text(response.get('execution_id'))} Commit: {_text(response.get('commit_sha'))}") + click.echo(f"Reporting: {_text(response.get('reporting_state'))} Results: {_text(response.get('status'))}") + if response.get("reason"): + click.echo(f"Reason: {_text(response['reason'])}") + click.echo(f"Retained occurrences: {_text(response.get('total_count'))}; shown: {len(response['data'])}") + rows = response["data"] + if rows: + columns = ("suite", "class_name", "name", "outcome", "duration_ms") + output_list( + [{key: _text(row.get(key)) for key in columns} for row in rows], + columns=columns, + column_labels=["Suite", "Class", "Test", "Outcome", "Time (ms)"], + ) + for row in rows: + if row.get("failure_summary"): + # The class keeps same-named tests in different classes apart. + label = ".".join(_text(row.get(key)) for key in ("class_name", "name") if row.get(key)) + click.echo(f"\n{label or 'unknown'}: {_text(row['failure_summary'])}") + elif response.get("status") == "available" and response.get("total_count") == 0: + click.echo("The retained report contains zero test occurrences. This does not establish that no tests ran.") + elif response.get("status") == "available": + click.echo("No more results.") + else: + click.echo("No test results returned. Missing or expired reports do not mean that zero tests ran.") + if response.get("reporting_state") == "complete": + click.echo("Complete describes the collected reports, not whether every expected test ran.") + + +@click.group(cls=JobTestsGroup) +@click.pass_context +def tests(ctx: click.Context) -> None: + """Read a job's tests, timings and recorded failure messages. + + Use avr tests JOB (or avr tests list JOB). JOB is an Avrea job ID + (job- or awj-) from avr job list. + """ + ensure_ctx(ctx) + + +@tests.command("list") +@click.argument("job", metavar="JOB", callback=_job_id) +@click.option("--org", "org_id", help="Organization ID or slug. Defaults to the configured organization.") +@click.option("--repo", "repo_id", help="Repository (owner/name or rep-ID). Defaults to the job's repository.") +@click.option( + "-L", + "--limit", + type=click.IntRange(1, 1000), + default=100, + show_default=True, + help=f"Maximum occurrences to return; short pages are followed, up to {_MAX_PAGES} result pages.", +) +@click.option("--cursor", help="Continue from a previous response's next_cursor.") +@json_options +@click.pass_context +def tests_list( + ctx: click.Context, + job: str, + org_id: str | None, + repo_id: str | None, + limit: int, + cursor: str | None, + json_fields: str | None, + jq_expr: str | None, +) -> None: + """Read the latest confirmed execution of JOB, including repeated tests. + + Names, outcomes, recorded durations and available failure messages are + shown together. A failed test is data, not a command error. Missing, + pending, partial and expired evidence are reported explicitly; an older + execution is never substituted. Complete describes collected reports. + + Use --json '*' for the complete response or --json FIELDS to select + fields. Continue with --cursor when pagination.next_cursor is present. + API refusal or a changed report fails the command without partial output. + """ + if handle_json_meta(json_fields, jq_expr, _FIELDS): + return + cursor = validate_cursor(cursor, max_length=1024) + client: ApiClient = ctx.obj["client"] + config: CliConfig = ctx.obj["config"] + ensure_authenticated(config) + organization = get_org_id(config, org_id, client=client) + repository = ( + resolve_repo(client, config, organization, repo_id) if repo_id else _job_repository(client, organization, job) + ) + response = _results(client, f"/orgs/{organization}/repos/{repository}/jobs/{job}/tests", job, limit, cursor) + response.update(organization_id=organization, repository_id=repository) + if json_fields is not None: + emit_json_record(response, split_fields(json_fields, _FIELDS), _FIELDS, jq_expr) + else: + _show(response) + next_cursor = response["pagination"]["next_cursor"] + if next_cursor: + click.echo(f"More results available. Continue with --cursor {shlex.quote(_text(next_cursor))}", err=True) diff --git a/avrea_cli/helpers.py b/avrea_cli/helpers.py index b5452312..3cc6821a 100644 --- a/avrea_cli/helpers.py +++ b/avrea_cli/helpers.py @@ -267,13 +267,13 @@ def get_verified_org_slug(client: ApiClient, org_id: str) -> str: raise click.ClickException("Could not verify the selected organization for this Avrea run URL.") -# Opaque base64 cursors are short — Avrea's cursors are well under 200 chars. +# Most list cursors are short; endpoints with snapshot cursors can set a larger bound. # 512 leaves headroom for protocol changes while still rejecting paste-mistakes # (e.g. a whole file pasted into --cursor) before they hit the server. _MAX_CURSOR_LEN = 512 -def validate_cursor(cursor: str | None) -> str | None: +def validate_cursor(cursor: str | None, *, max_length: int = _MAX_CURSOR_LEN) -> str | None: """Bound a user-supplied pagination cursor at the CLI boundary. Cursors are opaque base64 from a previous response, so the only useful @@ -284,9 +284,9 @@ def validate_cursor(cursor: str | None) -> str | None: through.""" if cursor is None: return None - if not cursor or cursor.strip() != cursor or len(cursor) > _MAX_CURSOR_LEN: + if not cursor or cursor.strip() != cursor or len(cursor) > max_length: raise click.BadParameter( - f"cursor must be 1..{_MAX_CURSOR_LEN} chars and not surrounded by whitespace (got {len(cursor)} chars)", + f"cursor must be 1..{max_length} chars and not surrounded by whitespace (got {len(cursor)} chars)", param_hint="--cursor", ) return cursor diff --git a/avrea_cli/main.py b/avrea_cli/main.py index 2fc6e85d..93c954b6 100644 --- a/avrea_cli/main.py +++ b/avrea_cli/main.py @@ -38,6 +38,13 @@ ("vm", "avrea_cli.vm", "vm", "Manage long-running VMs (SSH/RDP/VNC).", "Core Commands"), ("workflow", "avrea_cli.commands.workflow", "workflow", "List and view workflow definitions.", "Core Commands"), ("cache", "avrea_cli.commands.cache", "cache", "Inspect and manage the Avrea build cache.", "Core Commands"), + ( + "tests", + "avrea_cli.commands.tests", + "tests", + "Read a job's tests, timings and recorded failure messages.", + "Core Commands", + ), ("sbom", "avrea_cli.commands.sbom", "sbom", "View, generate, and download repository SBOMs.", "Core Commands"), ("log", "avrea_cli.commands.log", "log", "Search across runner execution logs.", "Core Commands"), ("auth", "avrea_cli.commands.auth_cmd", "auth_group", "Authenticate and manage credentials.", "Setup & Config"), diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7cee30e2..b444fe5b 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -42,6 +42,7 @@ avr [GLOBAL OPTIONS] COMMAND [ARGS]... - [`avr vm`](#avr-vm) — Manage long-running VMs (SSH/RDP/VNC). - [`avr workflow`](#avr-workflow) — List and view workflow definitions. - [`avr cache`](#avr-cache) — Inspect and manage the Avrea build cache. +- [`avr tests`](#avr-tests) — Read a job's tests, timings and recorded failure messages. - [`avr sbom`](#avr-sbom) — View, generate, and download repository SBOMs. - [`avr log`](#avr-log) — Search across runner execution logs. @@ -1303,6 +1304,47 @@ JSON FIELDS - `--json ` — Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields. - `-q, --jq ` — Filter --json output through a jq expression. +### `avr tests` + +Read a job's tests, timings and recorded failure messages. + +```sh +avr tests [OPTIONS] COMMAND [ARGS]... +``` + +Use avr tests JOB (or avr tests list JOB). JOB is an Avrea job ID +(job- or awj-) from avr job list. + +#### `avr tests list` + +Read the latest confirmed execution of JOB, including repeated tests. + +```sh +avr tests list [OPTIONS] JOB +``` + +Names, outcomes, recorded durations and available failure messages are +shown together. A failed test is data, not a command error. Missing, +pending, partial and expired evidence are reported explicitly; an older +execution is never substituted. Complete describes collected reports. + +Use --json '*' for the complete response or --json FIELDS to select +fields. Continue with --cursor when pagination.next_cursor is present. +API refusal or a changed report fails the command without partial output. + +**Arguments** + +- `JOB` + +**Options** + +- `--org ` — Organization ID or slug. Defaults to the configured organization. +- `--repo ` — Repository (owner/name or rep-ID). Defaults to the job's repository. +- `-L, --limit ` — Maximum occurrences to return; short pages are followed, up to 20 result pages. _(default: `100`)_ +- `--cursor ` — Continue from a previous response's next_cursor. +- `--json ` — Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields. +- `-q, --jq ` — Filter --json output through a jq expression. + ### `avr sbom` View, generate, and download repository SBOMs. diff --git a/docs/reference.json b/docs/reference.json index 2f2e1c16..dcc512d6 100644 --- a/docs/reference.json +++ b/docs/reference.json @@ -6248,6 +6248,171 @@ } ] }, + { + "name": "tests", + "path": [ + "avr", + "tests" + ], + "full": "avr tests", + "is_group": true, + "short_help": "Read a job's tests, timings and recorded failure messages.", + "help": "Read a job's tests, timings and recorded failure messages.\n\nUse avr tests JOB (or avr tests list JOB). JOB is an Avrea job ID\n(job- or awj-) from avr job list.\n", + "epilog": null, + "deprecated": false, + "hidden": false, + "usage": "avr tests [OPTIONS] COMMAND [ARGS]...", + "params": [], + "subcommands": [ + { + "name": "list", + "path": [ + "avr", + "tests", + "list" + ], + "full": "avr tests list", + "is_group": false, + "short_help": "Read the latest confirmed execution of JOB, including repeated tests.", + "help": "Read the latest confirmed execution of JOB, including repeated tests.\n\nNames, outcomes, recorded durations and available failure messages are\nshown together. A failed test is data, not a command error. Missing,\npending, partial and expired evidence are reported explicitly; an older\nexecution is never substituted. Complete describes collected reports.\n\nUse --json '*' for the complete response or --json FIELDS to select\nfields. Continue with --cursor when pagination.next_cursor is present.\nAPI refusal or a changed report fails the command without partial output.\n", + "epilog": null, + "deprecated": false, + "hidden": false, + "usage": "avr tests list [OPTIONS] JOB", + "params": [ + { + "name": "job", + "kind": "argument", + "opts": [ + "job" + ], + "secondary_opts": [], + "type": "text", + "required": true, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": null, + "hidden": false, + "show_default": false, + "default": null + }, + { + "name": "org_id", + "kind": "option", + "opts": [ + "--org" + ], + "secondary_opts": [], + "type": "text", + "required": false, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": "Organization ID or slug. Defaults to the configured organization.", + "hidden": false, + "show_default": false, + "default": null + }, + { + "name": "repo_id", + "kind": "option", + "opts": [ + "--repo" + ], + "secondary_opts": [], + "type": "text", + "required": false, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": "Repository (owner/name or rep-ID). Defaults to the job's repository.", + "hidden": false, + "show_default": false, + "default": null + }, + { + "name": "limit", + "kind": "option", + "opts": [ + "-L", + "--limit" + ], + "secondary_opts": [], + "type": "integer range", + "required": false, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": "Maximum occurrences to return; short pages are followed, up to 20 result pages.", + "hidden": false, + "show_default": true, + "default": 100 + }, + { + "name": "cursor", + "kind": "option", + "opts": [ + "--cursor" + ], + "secondary_opts": [], + "type": "text", + "required": false, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": "Continue from a previous response's next_cursor.", + "hidden": false, + "show_default": false, + "default": null + }, + { + "name": "json_fields", + "kind": "option", + "opts": [ + "--json" + ], + "secondary_opts": [], + "type": "text", + "required": false, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": "Output JSON. Pass comma-separated field names, \"*\" for all fields, or \"?\" to list available fields.", + "hidden": false, + "show_default": false, + "default": null + }, + { + "name": "jq_expr", + "kind": "option", + "opts": [ + "-q", + "--jq" + ], + "secondary_opts": [], + "type": "text", + "required": false, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": "Filter --json output through a jq expression.", + "hidden": false, + "show_default": false, + "default": null + } + ], + "subcommands": [] + } + ] + }, { "name": "sbom", "path": [ diff --git a/docs/starlight/_cli-sidebar.mjs b/docs/starlight/_cli-sidebar.mjs index 185c7e2d..c7cf89f7 100644 --- a/docs/starlight/_cli-sidebar.mjs +++ b/docs/starlight/_cli-sidebar.mjs @@ -18,6 +18,7 @@ export default { { label: "avr vm", slug: "cli/reference/vm" }, { label: "avr workflow", slug: "cli/reference/workflow" }, { label: "avr cache", slug: "cli/reference/cache" }, + { label: "avr tests", slug: "cli/reference/tests" }, { label: "avr sbom", slug: "cli/reference/sbom" }, { label: "avr log", slug: "cli/reference/log" }, ], diff --git a/docs/starlight/index.md b/docs/starlight/index.md index d1a88b87..06ba582a 100644 --- a/docs/starlight/index.md +++ b/docs/starlight/index.md @@ -162,6 +162,26 @@ avr run logs # full log dump, paginated avr job logs --follow # tail one job in real time ``` +**Read a job's recorded tests** + +```sh +avr tests # names, outcomes, timings and failure messages +avr tests --json '*' # results with reporting state and pagination +avr tests --limit 500 # follow short pages up to the requested count +avr tests --cursor '' # continue with pagination.next_cursor +``` + +Use the Avrea job ID (`job-` or `awj-`) from `avr job list`; the repository is +resolved from that job. `avr tests list ` is equivalent. `--json FIELDS` and `--jq` also +work. Each invocation reads at most 1,000 occurrences and 20 result pages, +preserving repeated tests and returning a cursor when more results remain. +Missing or expired reports are distinct from a retained report containing zero +tests. A report marked complete does not establish that every expected test ran. + +This requires an API deployment supporting job test results and access to the +repository's test evidence. Only Avrea job IDs are supported. The command does +not rerun tests or change collection. + **Trigger a workflow_dispatch** ```sh diff --git a/docs/starlight/reference/index.md b/docs/starlight/reference/index.md index 23fa6a8e..6aa1eb34 100644 --- a/docs/starlight/reference/index.md +++ b/docs/starlight/reference/index.md @@ -30,6 +30,7 @@ avr [GLOBAL OPTIONS] COMMAND [ARGS]... - [`avr vm`](./vm/) — Manage long-running VMs (SSH/RDP/VNC). - [`avr workflow`](./workflow/) — List and view workflow definitions. - [`avr cache`](./cache/) — Inspect and manage the Avrea build cache. +- [`avr tests`](./tests/) — Read a job's tests, timings and recorded failure messages. - [`avr sbom`](./sbom/) — View, generate, and download repository SBOMs. - [`avr log`](./log/) — Search across runner execution logs. diff --git a/docs/starlight/reference/tests.md b/docs/starlight/reference/tests.md new file mode 100644 index 00000000..a481f7d4 --- /dev/null +++ b/docs/starlight/reference/tests.md @@ -0,0 +1,45 @@ +--- +title: avr tests +description: "Read a job's tests, timings and recorded failure messages." +--- + +Read a job's tests, timings and recorded failure messages. + +```sh +avr tests [OPTIONS] COMMAND [ARGS]... +``` + +Use avr tests JOB (or avr tests list JOB). JOB is an Avrea job ID +(job- or awj-) from avr job list. + +## Subcommands + +### `avr tests list` + +Read the latest confirmed execution of JOB, including repeated tests. + +```sh +avr tests list [OPTIONS] JOB +``` + +Names, outcomes, recorded durations and available failure messages are +shown together. A failed test is data, not a command error. Missing, +pending, partial and expired evidence are reported explicitly; an older +execution is never substituted. Complete describes collected reports. + +Use --json '*' for the complete response or --json FIELDS to select +fields. Continue with --cursor when pagination.next_cursor is present. +API refusal or a changed report fails the command without partial output. + +**Arguments** + +- JOB + +**Options** + +- --org <TEXT> — Organization ID or slug. Defaults to the configured organization. +- --repo <TEXT> — Repository (owner/name or rep-ID). Defaults to the job's repository. +- -L, --limit <INTEGER RANGE> — Maximum occurrences to return; short pages are followed, up to 20 result pages. _(default: `100`)_ +- --cursor <TEXT> — Continue from a previous response's next_cursor. +- --json <TEXT> — Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields. +- -q, --jq <TEXT> — Filter --json output through a jq expression. diff --git a/man/man1/avr-artifact.1 b/man/man1/avr-artifact.1 index 26e957e3..c0eea362 100644 --- a/man/man1/avr-artifact.1 +++ b/man/man1/avr-artifact.1 @@ -29,6 +29,7 @@ List artifacts in REPO, optionally restricted to one run. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-audit-events.1 b/man/man1/avr-audit-events.1 index 58939f2b..27a732a7 100644 --- a/man/man1/avr-audit-events.1 +++ b/man/man1/avr-audit-events.1 @@ -21,6 +21,7 @@ List audit events for the organization. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-auth.1 b/man/man1/avr-auth.1 index 203caefc..d1c758d7 100644 --- a/man/man1/avr-auth.1 +++ b/man/man1/avr-auth.1 @@ -30,6 +30,7 @@ Switch the default host used when AVR_HOST isn't set. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-token (1), diff --git a/man/man1/avr-billing.1 b/man/man1/avr-billing.1 index 9762073a..be771b67 100644 --- a/man/man1/avr-billing.1 +++ b/man/man1/avr-billing.1 @@ -33,6 +33,7 @@ Update billing settings. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-cache.1 b/man/man1/avr-cache.1 index cd3623f8..35994e4d 100644 --- a/man/man1/avr-cache.1 +++ b/man/man1/avr-cache.1 @@ -26,6 +26,7 @@ Show cache usage summary for a repository. .BR avr-job (1), .BR avr-vm (1), .BR avr-workflow (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-config.1 b/man/man1/avr-config.1 index fdcfacaf..122b42a8 100644 --- a/man/man1/avr-config.1 +++ b/man/man1/avr-config.1 @@ -30,6 +30,7 @@ Remove a configuration override. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-firewall.1 b/man/man1/avr-firewall.1 index 7dbdbd71..690c60a8 100644 --- a/man/man1/avr-firewall.1 +++ b/man/man1/avr-firewall.1 @@ -39,6 +39,7 @@ Show the resolved (merged) firewall rule list for a repository. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-health.1 b/man/man1/avr-health.1 index 981ba5b0..ed6cf837 100644 --- a/man/man1/avr-health.1 +++ b/man/man1/avr-health.1 @@ -32,6 +32,7 @@ Filter --json output through a jq expression. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-job.1 b/man/man1/avr-job.1 index d1e2fe05..abc782a9 100644 --- a/man/man1/avr-job.1 +++ b/man/man1/avr-job.1 @@ -35,6 +35,7 @@ Watch active jobs with auto-refresh (Ctrl+C to stop). .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-log.1 b/man/man1/avr-log.1 index 162c2bb7..349bdb4c 100644 --- a/man/man1/avr-log.1 +++ b/man/man1/avr-log.1 @@ -21,6 +21,7 @@ Search logs for a repository. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-auth (1), .BR avr-token (1), diff --git a/man/man1/avr-org.1 b/man/man1/avr-org.1 index b081c3d3..5610a106 100644 --- a/man/man1/avr-org.1 +++ b/man/man1/avr-org.1 @@ -36,6 +36,7 @@ Configure SAML single sign-on for an organization. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-pr.1 b/man/man1/avr-pr.1 index edf214d4..ed4f29e1 100644 --- a/man/man1/avr-pr.1 +++ b/man/man1/avr-pr.1 @@ -20,6 +20,7 @@ List pull requests across repositories. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-repo.1 b/man/man1/avr-repo.1 index 11afc8bd..7943a7fb 100644 --- a/man/man1/avr-repo.1 +++ b/man/man1/avr-repo.1 @@ -30,6 +30,7 @@ Request and browse mirrors of public GitHub repositories. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-run.1 b/man/man1/avr-run.1 index 494d20ba..726bcfd2 100644 --- a/man/man1/avr-run.1 +++ b/man/man1/avr-run.1 @@ -38,6 +38,7 @@ Watch a workflow run until it completes. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-sbom.1 b/man/man1/avr-sbom.1 index 81c2e65c..08046f15 100644 --- a/man/man1/avr-sbom.1 +++ b/man/man1/avr-sbom.1 @@ -30,6 +30,7 @@ Show an SBOM's summary: dependency counts, licenses, and artifacts. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-log (1), .BR avr-auth (1), .BR avr-token (1), diff --git a/man/man1/avr-settings.1 b/man/man1/avr-settings.1 index a57adcab..2e3eb049 100644 --- a/man/man1/avr-settings.1 +++ b/man/man1/avr-settings.1 @@ -30,6 +30,7 @@ Set a setting value. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-status.1 b/man/man1/avr-status.1 index e7948ec7..88a4f3e4 100644 --- a/man/man1/avr-status.1 +++ b/man/man1/avr-status.1 @@ -29,6 +29,7 @@ Output raw JSON. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-tests-list.1 b/man/man1/avr-tests-list.1 new file mode 100644 index 00000000..f1016131 --- /dev/null +++ b/man/man1/avr-tests-list.1 @@ -0,0 +1,38 @@ +.TH AVR\-TESTS\-LIST 1 "" "avr 0.5.0" "Avrea CLI" +.SH NAME +avr-tests-list \- Read the latest confirmed execution of JOB, including repeated tests. +.SH SYNOPSIS +.B avr tests list +[OPTIONS] JOB +.SH DESCRIPTION +Read the latest confirmed execution of JOB, including repeated tests. +.PP +Names, outcomes, recorded durations and available failure messages are shown together. A failed test is data, not a command error. Missing, pending, partial and expired evidence are reported explicitly; an older execution is never substituted. Complete describes collected reports. +.PP +Use --json '*' for the complete response or --json FIELDS to select fields. Continue with --cursor when pagination.next_cursor is present. API refusal or a changed report fails the command without partial output. +.SH ARGUMENTS +.TP +\fIJOB\fR +\& +.SH OPTIONS +.TP +\fB\-\-org\fR \fITEXT\fR +Organization ID or slug. Defaults to the configured organization. +.TP +\fB\-\-repo\fR \fITEXT\fR +Repository (owner/name or rep-ID). Defaults to the job's repository. +.TP +\fB\-L\fR, \fB\-\-limit\fR \fIINTEGER RANGE\fR +Maximum occurrences to return; short pages are followed, up to 20 result pages. (default: 100) +.TP +\fB\-\-cursor\fR \fITEXT\fR +Continue from a previous response's next_cursor. +.TP +\fB\-\-json\fR \fITEXT\fR +Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields. +.TP +\fB\-q\fR, \fB\-\-jq\fR \fITEXT\fR +Filter --json output through a jq expression. +.SH SEE ALSO +.BR avr (1), +.BR avr-tests (1) diff --git a/man/man1/avr-tests.1 b/man/man1/avr-tests.1 new file mode 100644 index 00000000..a8fca011 --- /dev/null +++ b/man/man1/avr-tests.1 @@ -0,0 +1,37 @@ +.TH AVR\-TESTS 1 "" "avr 0.5.0" "Avrea CLI" +.SH NAME +avr-tests \- Read a job's tests, timings and recorded failure messages. +.SH SYNOPSIS +.B avr tests +[OPTIONS] COMMAND [ARGS]... +.SH DESCRIPTION +Read a job's tests, timings and recorded failure messages. +.PP +Use avr tests JOB (or avr tests list JOB). JOB is an Avrea job ID (job- or awj-) from avr job list. +.SH COMMANDS +.TP +list +Read the latest confirmed execution of JOB, including repeated tests. +.SH SEE ALSO +.BR avr (1), +.BR avr-artifact (1), +.BR avr-status (1), +.BR avr-run (1), +.BR avr-pr (1), +.BR avr-job (1), +.BR avr-vm (1), +.BR avr-workflow (1), +.BR avr-cache (1), +.BR avr-sbom (1), +.BR avr-log (1), +.BR avr-auth (1), +.BR avr-token (1), +.BR avr-config (1), +.BR avr-settings (1), +.BR avr-firewall (1), +.BR avr-billing (1), +.BR avr-audit-events (1), +.BR avr-repo (1), +.BR avr-org (1), +.BR avr-health (1), +.BR avr-tests-list (1) diff --git a/man/man1/avr-token.1 b/man/man1/avr-token.1 index d3aa186b..e8d92781 100644 --- a/man/man1/avr-token.1 +++ b/man/man1/avr-token.1 @@ -31,6 +31,7 @@ Show a token's details and grants, including a revoked or expired one. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-vm.1 b/man/man1/avr-vm.1 index 018e6410..fab7ad48 100644 --- a/man/man1/avr-vm.1 +++ b/man/man1/avr-vm.1 @@ -65,6 +65,7 @@ Open a VNC desktop on a macOS VM (Screen Sharing) over an SSH tunnel. .BR avr-job (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr-workflow.1 b/man/man1/avr-workflow.1 index f8e51354..f5ade8c8 100644 --- a/man/man1/avr-workflow.1 +++ b/man/man1/avr-workflow.1 @@ -26,6 +26,7 @@ View a workflow with aggregate stats and per-job breakdown. .BR avr-job (1), .BR avr-vm (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/man/man1/avr.1 b/man/man1/avr.1 index 128f2ef0..bce0dfa2 100644 --- a/man/man1/avr.1 +++ b/man/man1/avr.1 @@ -46,6 +46,9 @@ List and view workflow definitions. cache Inspect and manage the Avrea build cache. .TP +tests +Read a job's tests, timings and recorded failure messages. +.TP sbom View, generate, and download repository SBOMs. .TP @@ -90,6 +93,7 @@ Check Avrea platform status. .BR avr-vm (1), .BR avr-workflow (1), .BR avr-cache (1), +.BR avr-tests (1), .BR avr-sbom (1), .BR avr-log (1), .BR avr-auth (1), diff --git a/tests/test_tests.py b/tests/test_tests.py new file mode 100644 index 00000000..7d6de936 --- /dev/null +++ b/tests/test_tests.py @@ -0,0 +1,331 @@ +"""Job results are read through the authorized repository and exact API cursor.""" + +from avrea_cli.main import cli +from click.testing import CliRunner +from tests.conftest import FakeApi +from typing import Any +import json +import pytest + +ORG = "org-019a0000000070008000000000000001" +REPO = "rep-019a0000000070008000000000000002" +JOB = "job-019a0000000070008000000000000003" +NATIVE_JOB = "awj-019a0000000070008000000000000007" +EXECUTION = "exe-019a0000000070008000000000000004" +JOB_PATH = f"/orgs/{ORG}/jobs/{JOB}" +RESULTS_PATH = f"/orgs/{ORG}/repos/{REPO}/jobs/{JOB}/tests" +SCOPE = ["--org", ORG] +ROW = { + "test_id": "a" * 64, + "suite": "checkout", + "class_name": "TestCheckout", + "name": "test_payment", + "outcome": "failed", + "duration_ms": 21, + "failure_summary": "expected 200, got 500", +} + + +def page(rows: list[dict[str, Any]], *, cursor: str | None = None, **changes: Any) -> dict[str, Any]: + return { + "job_id": JOB, + "execution_id": EXECUTION, + "status": "available", + "reporting_state": "complete", + "reason": None, + "commit_sha": "b" * 40, + "observed_at": "2026-10-28T12:00:00Z", + "total_count": 3, + "data": rows, + "pagination": {"next_cursor": cursor}, + **changes, + } + + +@pytest.fixture +def job_api(api: FakeApi) -> FakeApi: + api.reply("GET", JOB_PATH, json={"data": {"job_id": JOB, "repository_id": REPO, "job_name": "Unit tests"}}) + return api + + +def test_job_short_and_empty_pages_continue_without_dropping_repeats(runner: CliRunner, job_api: FakeApi) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW], cursor="first")) + job_api.reply("GET", RESULTS_PATH, json=page([], cursor="second")) + job_api.reply("GET", RESULTS_PATH, json=page([ROW, {**ROW, "outcome": "passed"}])) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["organization_id"] == ORG + assert data["repository_id"] == REPO + assert data["job_id"] == JOB + assert data["execution_id"] == EXECUTION + assert data["data"] == [ROW, ROW, {**ROW, "outcome": "passed"}] + assert data["pagination"]["next_cursor"] is None + assert [r.url.params.get("cursor") for r in job_api.sent("GET", RESULTS_PATH)] == [None, "first", "second"] + + +@pytest.mark.parametrize( + "state", ["pending", "missing", "skipped", "no_report", "partial", "complete", "failed", "not_recorded"] +) +def test_job_reporting_is_separate_from_availability(runner: CliRunner, job_api: FakeApi, state: str) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([], reporting_state=state, status="not_recorded", total_count=None)) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["reporting_state"] == state + assert data["status"] == "not_recorded" + assert data["total_count"] is None + + +def test_job_text_shows_failure_and_escapes_terminal_controls(runner: CliRunner, job_api: FakeApi) -> None: + row = {**ROW, "name": "test_\x1b[31mpayment", "failure_summary": "bad\r\nline\x1b[2J"} + job_api.reply("GET", RESULTS_PATH, json=page([row])) + result = runner.invoke(cli, ["tests", JOB, *SCOPE]) + assert result.exit_code == 0, result.output + assert "payment" in result.output + assert "failed" in result.output + assert "21" in result.output + assert "bad\\r\\nline" in result.output + assert "\x1b" not in result.output + assert "Reporting" in result.output + assert "Complete describes the collected reports" in result.output + + +def test_job_text_shows_the_reporting_reason(runner: CliRunner, job_api: FakeApi) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW], reporting_state="partial", reason="upload_failed")) + result = runner.invoke(cli, ["tests", JOB, *SCOPE]) + assert result.exit_code == 0, result.output + assert "Reason: upload_failed" in result.stdout + assert "Complete describes" not in result.stdout + + +@pytest.mark.parametrize("code", [403, 404, 409, 429, 503]) +def test_job_page_failure_never_emits_partial_success(runner: CliRunner, job_api: FakeApi, code: int) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW], cursor="next")) + job_api.reply("GET", RESULTS_PATH, status=code, json={"detail": "Report changed or unavailable"}) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 1 + assert f"HTTP {code}" in result.stderr + assert not result.stdout + + +def test_results_not_found_says_what_to_check(runner: CliRunner, job_api: FakeApi) -> None: + job_api.reply("GET", RESULTS_PATH, status=404, json={"detail": "Not Found"}) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--cursor", "stale"]) + assert result.exit_code == 1 + assert "restart without it" in result.stderr + assert "Repository Tests are enabled" in result.stderr + + +def test_native_job_ids_are_read_like_job_ids(runner: CliRunner, api: FakeApi) -> None: + native_results = f"/orgs/{ORG}/repos/{REPO}/jobs/{NATIVE_JOB}/tests" + api.reply("GET", f"/orgs/{ORG}/jobs/{NATIVE_JOB}", json={"data": {"job_id": NATIVE_JOB, "repository_id": REPO}}) + api.reply("GET", native_results, json={**page([ROW]), "job_id": NATIVE_JOB}) + result = runner.invoke(cli, ["tests", NATIVE_JOB, *SCOPE, "--json", "job_id,data"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"job_id": NATIVE_JOB, "data": [ROW]} + assert len(api.sent("GET", native_results)) == 1 + + +def test_explicit_repository_does_not_require_job_metadata(runner: CliRunner, api: FakeApi) -> None: + api.reply("GET", RESULTS_PATH, json=page([ROW])) + result = runner.invoke(cli, ["tests", "list", JOB, *SCOPE, "--repo", REPO, "--json", "data,status"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"data": [ROW], "status": "available"} + assert len(api.requests) == 1 + + +def test_limit_preserves_next_cursor_and_request_sizes(runner: CliRunner, job_api: FakeApi) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW], cursor="one")) + job_api.reply("GET", RESULTS_PATH, json=page([ROW], cursor="two")) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--limit", "2", "--json", "*"]) + assert result.exit_code == 0, result.output + response = json.loads(result.stdout) + assert len(response["data"]) == 2 + assert response["pagination"] == {"next_cursor": "two"} + assert "--cursor two" in result.stderr + assert [dict(r.url.params) for r in job_api.sent("GET", RESULTS_PATH)] == [ + {"limit": "2"}, + {"limit": "1", "cursor": "one"}, + ] + + +def test_large_limit_is_read_in_one_request_when_the_page_holds_it(runner: CliRunner, job_api: FakeApi) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW] * 1000, total_count=1000)) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--limit", "1000", "--json", "*"]) + assert result.exit_code == 0, result.output + assert len(json.loads(result.stdout)["data"]) == 1000 + assert [dict(r.url.params) for r in job_api.sent("GET", RESULTS_PATH)] == [{"limit": "1000"}] + + +def test_failure_messages_name_the_class_of_same_named_tests(runner: CliRunner, job_api: FakeApi) -> None: + rows = [ + {**ROW, "class_name": "TestCart", "name": "test_init", "failure_summary": "cart failed"}, + {**ROW, "class_name": "TestOrder", "name": "test_init", "failure_summary": "order failed"}, + ] + job_api.reply("GET", RESULTS_PATH, json=page(rows)) + result = runner.invoke(cli, ["tests", JOB, *SCOPE]) + assert result.exit_code == 0, result.output + assert "TestCart.test_init: cart failed" in result.stdout + assert "TestOrder.test_init: order failed" in result.stdout + + +def test_page_budget_returns_a_resume_cursor(runner: CliRunner, job_api: FakeApi) -> None: + for i in range(20): + job_api.reply("GET", RESULTS_PATH, json=page([], cursor=f"page-{i}")) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 0, result.output + assert len(job_api.sent("GET", RESULTS_PATH)) == 20 + assert json.loads(result.stdout)["pagination"]["next_cursor"] == "page-19" + assert "page-19" in result.stderr + + +@pytest.mark.parametrize( + "changes", + [ + {"execution_id": "exe-019a0000000070008000000000000005"}, + {"status": "expired"}, + {"reporting_state": "partial"}, + {"reason": "upload_failed"}, + {"commit_sha": "c" * 40}, + {"observed_at": "2026-10-28T12:05:00Z"}, + {"total_count": 4}, + ], +) +def test_changed_job_pages_are_not_combined(runner: CliRunner, job_api: FakeApi, changes: dict[str, Any]) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW], cursor="next")) + job_api.reply("GET", RESULTS_PATH, json=page([ROW], **changes)) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 1 + assert "Job results changed while reading pages" in result.stderr + assert not result.stdout + + +@pytest.mark.parametrize("page_number", [0, 1]) +def test_a_page_for_another_job_is_rejected(runner: CliRunner, job_api: FakeApi, page_number: int) -> None: + other = {"job_id": "job-019a0000000070008000000000000006"} + pages = [page([ROW], cursor="next"), page([ROW])] + pages[page_number] = {**pages[page_number], **other} + for body in pages[: page_number + 1]: + job_api.reply("GET", RESULTS_PATH, json=body) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 1 + assert "invalid job test results" in result.stderr + assert not result.stdout + + +def test_a_page_larger_than_requested_is_rejected(runner: CliRunner, job_api: FakeApi) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW, ROW, ROW])) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--limit", "2", "--json", "*"]) + assert result.exit_code == 1 + assert "invalid test result page" in result.stderr + assert not result.stdout + + +def test_job_lookup_for_another_job_is_rejected(runner: CliRunner, api: FakeApi) -> None: + other = "job-019a0000000070008000000000000006" + api.reply("GET", JOB_PATH, json={"data": {"job_id": other, "repository_id": REPO}}) + result = runner.invoke(cli, ["tests", JOB, *SCOPE]) + assert result.exit_code == 1 + assert "returned a different job" in result.stderr + assert len(api.requests) == 1 + + +@pytest.mark.parametrize( + "bad_page,message", + [ + (page([ROW], cursor="next"), "repeated a test-results cursor"), + (page([ROW], cursor=" "), "invalid next cursor"), + (page([ROW], cursor=""), "invalid next cursor"), + (page([ROW], cursor="x" * 1025), "invalid next cursor"), + (page([ROW], pagination={"next_cursor": 17}), "invalid next cursor"), + (page([ROW], data="invalid"), "invalid job test results"), + ], +) +def test_invalid_pagination_stops_without_partial_output( + runner: CliRunner, job_api: FakeApi, bad_page: dict[str, Any], message: str +) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW], cursor="next")) + job_api.reply("GET", RESULTS_PATH, json=bad_page) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 1 + assert message in result.stderr + assert "--cursor" not in result.stderr + assert not result.stdout + assert len(job_api.sent("GET", RESULTS_PATH)) == 2 + + +def test_cursor_resume_uses_the_exact_value(runner: CliRunner, job_api: FakeApi) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([ROW])) + cursor = "x" * 900 + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--cursor", cursor, "--json", "*"]) + assert result.exit_code == 0, result.output + assert job_api.sent("GET", RESULTS_PATH)[0].url.params["cursor"] == cursor + + +@pytest.mark.parametrize( + "args,exit_code", + [ + (["list", "not-a-job"], 2), + (["list", "awj-019a"], 2), + ([JOB, "--cursor", " "], 2), + ([JOB, "--cursor", "x" * 1025], 2), + ([JOB, "--limit", "0"], 2), + ([JOB, "--limit", "1001"], 2), + ([JOB, "--output", "json"], 2), + ([JOB, "--jq", ".data"], 2), + ([JOB, "--json", "capture_id"], 1), + ], +) +def test_invalid_options_make_no_requests(runner: CliRunner, api: FakeApi, args: list[str], exit_code: int) -> None: + result = runner.invoke(cli, ["tests", *args, *SCOPE]) + assert result.exit_code == exit_code + assert api.requests == [] + + +@pytest.mark.parametrize( + "state,total,message", + [ + ("expired", None, "Missing or expired reports"), + ("not_recorded", None, "Missing or expired reports"), + ("available", 0, "retained report contains zero"), + ("available", 3, "No more results."), + ], +) +def test_empty_results_explain_availability( + runner: CliRunner, job_api: FakeApi, state: str, total: int | None, message: str +) -> None: + job_api.reply("GET", RESULTS_PATH, json=page([], status=state, total_count=total)) + result = runner.invoke(cli, ["tests", JOB, *SCOPE]) + assert result.exit_code == 0, result.output + assert message in result.stdout + assert state in result.stdout + assert ("Missing or expired" in result.stdout) == (state != "available") + assert len(job_api.sent("GET", RESULTS_PATH)) == 1 + + +def test_wrong_organization_does_not_fall_back_to_repository_scan(runner: CliRunner, api: FakeApi) -> None: + api.reply("GET", JOB_PATH, status=404, json={"detail": "Job not found"}) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 1 + assert "read the job" in result.stderr + assert "pass --org" in result.stderr + assert not result.stdout + assert len(api.requests) == 1 + + +def test_json_preserves_raw_test_names_and_failure_text(runner: CliRunner, job_api: FakeApi) -> None: + row = {**ROW, "name": "line\nbreak", "failure_summary": "\x1b[31merror"} + job_api.reply("GET", RESULTS_PATH, json=page([row])) + result = runner.invoke(cli, ["tests", JOB, *SCOPE, "--json", "*"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["data"] == [row] + + +def test_help_only_advertises_the_shipped_job_journey(runner: CliRunner, api: FakeApi) -> None: + result = runner.invoke(cli, ["tests", "--help"]) + assert result.exit_code == 0, result.output + assert "avr tests JOB" in result.stdout + assert "history" not in result.stdout + assert "view" not in result.stdout + assert api.requests == []