09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
234 lines
11 KiB
Python
234 lines
11 KiB
Python
"""CLI integration tests for artifact commands.
|
|
|
|
These tests exercise the full CLI → Client → RPC path using VCR cassettes.
|
|
|
|
The ``artifact list --json`` test carries the depth-2 re-record-safe tiers
|
|
(issue #1452), adapted for artifacts:
|
|
|
|
* **Schema/invariants.** The ``--json`` envelope is an object with a list of
|
|
artifact items; ids are non-empty (but NOT forced UUID — some artifact ids are
|
|
numeric).
|
|
* **Cassette-derived value correctness (depth-2).** The CLI's emitted ids/count
|
|
are checked against an INDEPENDENT shallow projection of the recorded
|
|
``LIST_ARTIFACTS`` payload (``_cassette_expectations``). Because the CLI list
|
|
merges note-backed mind maps from a *separate* RPC, the CLI output is a
|
|
superset of the ``gArtLc`` projection — so this uses CONTAINMENT and a count
|
|
floor (``proj.ids ⊆ cli_ids``, ``len(cli) >= proj.count``), not equality.
|
|
* **Per-field semantic invariants.** ``assert_semantic_invariants`` pins per-field
|
|
meaning (``type_id``/``status`` are known enum values, ``created_at`` parses).
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from notebooklm.notebooklm_cli import cli
|
|
|
|
# Enum *code* sets — an allowed-membership floor for the projection's raw integer
|
|
# type/status codes (a set-membership check, not a decode; see #1452).
|
|
from notebooklm.rpc.types import ArtifactStatus, ArtifactTypeCode
|
|
|
|
from ._cassette_expectations import load_rpc_payload, project_artifact_list
|
|
from ._fixtures import ARTIFACT_NOTEBOOK_ID
|
|
from .conftest import (
|
|
assert_command_success,
|
|
assert_semantic_invariants,
|
|
notebooklm_vcr,
|
|
parse_json_dict,
|
|
parse_json_output,
|
|
skip_no_cassettes,
|
|
)
|
|
|
|
pytestmark = [pytest.mark.vcr, skip_no_cassettes]
|
|
|
|
# ``artifact list`` reads the recorded ``LIST_ARTIFACTS`` (``gArtLc``) payload —
|
|
# the same RPC the projection helper reads independently.
|
|
_LIST_ARTIFACTS_RPC_ID = "gArtLc"
|
|
|
|
_KNOWN_ARTIFACT_TYPE_CODES = frozenset(member.value for member in ArtifactTypeCode)
|
|
# ``0`` is the "unknown" status the CLI degrades an unrecognized code to (see
|
|
# ``conftest._ARTIFACT_STATUS_STR_VALUES`` which keeps ``artifact_status_to_str(0)``);
|
|
# tolerate it here so a re-record carrying a status-0 row is not a spurious failure.
|
|
_KNOWN_ARTIFACT_STATUS_CODES = frozenset(member.value for member in ArtifactStatus) | {0}
|
|
|
|
|
|
class TestArtifactListCommand:
|
|
"""Test 'notebooklm artifact list' command."""
|
|
|
|
@pytest.mark.parametrize("json_flag", [False, True])
|
|
@notebooklm_vcr.use_cassette("artifacts_list.yaml")
|
|
def test_artifact_list(self, runner, mock_auth_for_vcr, mock_context, json_flag):
|
|
"""List artifacts with optional --json flag."""
|
|
args = ["artifact", "list"]
|
|
if json_flag:
|
|
args.append("--json")
|
|
|
|
result = runner.invoke(cli, args)
|
|
assert_command_success(result)
|
|
|
|
if json_flag and result.exit_code == 0:
|
|
data = parse_json_output(result.output)
|
|
assert data is not None, "Expected valid JSON output"
|
|
assert isinstance(data, list | dict)
|
|
|
|
@notebooklm_vcr.use_cassette("artifacts_list.yaml")
|
|
def test_artifact_list_matches_cassette_projection(
|
|
self, runner, mock_auth_for_vcr, mock_context
|
|
):
|
|
"""Depth-2: the CLI's artifact ids/count + per-field meaning are checked
|
|
against an INDEPENDENT projection of the recorded ``LIST_ARTIFACTS`` payload.
|
|
|
|
Containment, not equality: ``artifact list`` merges note-backed mind maps
|
|
from a separate RPC, so the CLI output is a superset of the ``gArtLc``
|
|
projection. Artifact ids are also not all UUID-shaped, so this anchors on
|
|
id CONTAINMENT + a count floor (never a recorded value), which stays
|
|
re-record-safe — both sides are read from the same cassette.
|
|
"""
|
|
result = runner.invoke(cli, ["artifact", "list", "--json"])
|
|
assert_command_success(result)
|
|
data = parse_json_dict(result.output)
|
|
cli_items = data["artifacts"]
|
|
assert isinstance(cli_items, list)
|
|
|
|
payload = load_rpc_payload("artifacts_list.yaml", _LIST_ARTIFACTS_RPC_ID)
|
|
proj = project_artifact_list(payload)
|
|
assert proj.count > 0, "projection found no artifacts — cassette/projection drift"
|
|
|
|
for art in cli_items:
|
|
assert art.get("id"), f"artifact item is missing a non-empty id: {art!r}"
|
|
cli_ids = {art.get("id") for art in cli_items}
|
|
assert len(cli_ids) == len(cli_items), "CLI emitted a duplicate artifact id"
|
|
# Count floor: the CLI list is the gArtLc rows PLUS merged note-backed
|
|
# mind maps, so it can only be >= the projection's row count.
|
|
assert len(cli_items) >= proj.count, (
|
|
f"CLI emitted {len(cli_items)} artifacts, fewer than the "
|
|
f"{proj.count} recorded LIST_ARTIFACTS rows (a drop)"
|
|
)
|
|
# Containment: every recorded gArtLc artifact id must surface in the CLI
|
|
# output (none dropped); the projection ids are a subset of the CLI's.
|
|
assert proj.ids <= cli_ids, (
|
|
"recorded LIST_ARTIFACTS ids missing from CLI output (a drop): "
|
|
f"{sorted(proj.ids - cli_ids)}"
|
|
)
|
|
# No duplicate id within the recorded rows: each projected row has a
|
|
# distinct id, so the id set is exactly as large as the row count (a
|
|
# server-side duplicate would collapse the set below ``count``).
|
|
assert len(proj.ids) == proj.count, (
|
|
"recorded LIST_ARTIFACTS rows carry a duplicate id "
|
|
f"({proj.count} rows, {len(proj.ids)} distinct ids)"
|
|
)
|
|
|
|
# Per-field semantic invariants: type_id/status are known enum values and
|
|
# created_at parses — for EVERY CLI item, not just the projected subset.
|
|
for art in cli_items:
|
|
assert_semantic_invariants(art, "artifact")
|
|
|
|
# Type/status histogram consistency: every type code the projection saw
|
|
# is a known artifact type code, and likewise for status — the projection
|
|
# is coarser than the CLI's variant-aware mapping, so this checks the
|
|
# codes are in-range rather than equal to the CLI histogram.
|
|
for status_id in proj.status_codes:
|
|
assert status_id in _KNOWN_ARTIFACT_STATUS_CODES, (
|
|
f"recorded artifact status code {status_id} is not a known code"
|
|
)
|
|
for type_code in proj.type_codes:
|
|
assert type_code in _KNOWN_ARTIFACT_TYPE_CODES, (
|
|
f"recorded artifact type code {type_code} is not a known code"
|
|
)
|
|
|
|
|
|
class TestArtifactListByType:
|
|
"""Test 'notebooklm artifact list --type' command."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("artifact_type", "cassette"),
|
|
[
|
|
("quiz", "artifacts_list_quizzes.yaml"),
|
|
("report", "artifacts_list_reports.yaml"),
|
|
("video", "artifacts_list_video.yaml"),
|
|
("flashcard", "artifacts_list_flashcards.yaml"),
|
|
("infographic", "artifacts_list_infographics.yaml"),
|
|
("slide-deck", "artifacts_list_slide_decks.yaml"),
|
|
("data-table", "artifacts_list_data_tables.yaml"),
|
|
("mind-map", "notes_list_mind_maps.yaml"),
|
|
],
|
|
)
|
|
def test_artifact_list_by_type(
|
|
self, runner, mock_auth_for_vcr, mock_context, artifact_type, cassette
|
|
):
|
|
"""List artifacts filtered by type.
|
|
|
|
For INFOGRAPHIC and DATA_TABLE we additionally assert the rendered
|
|
JSON output exposes the parsed ``type_id`` matching the requested
|
|
filter — proving the parser, not just the transport, agrees on the
|
|
kind.
|
|
"""
|
|
# only the INFOGRAPHIC + DATA_TABLE rows opt into ``--json``.
|
|
# The other rows stay on the table renderer to preserve their
|
|
# historical (xfail-masked) call sequence — the ``--json`` path
|
|
# makes an extra ``notebooks.get()`` RPC for the table header that
|
|
# several legacy cassettes do not have recorded.
|
|
is_target_type = artifact_type in {"infographic", "data-table"}
|
|
args = ["artifact", "list", "--type", artifact_type]
|
|
if is_target_type:
|
|
args.append("--json")
|
|
|
|
with notebooklm_vcr.use_cassette(cassette):
|
|
result = runner.invoke(cli, args)
|
|
assert_command_success(result)
|
|
|
|
# Parser-shape sanity check for the two types this task targets.
|
|
if is_target_type and result.exit_code == 0:
|
|
data = parse_json_output(result.output)
|
|
assert isinstance(data, dict)
|
|
artifacts = data.get("artifacts", [])
|
|
assert isinstance(artifacts, list)
|
|
# The recorded cassettes each contain one artifact of the
|
|
# requested kind. ``type_id`` is the user-facing string enum
|
|
# value (``"infographic"`` / ``"data_table"``); the CLI maps
|
|
# the kebab-case filter to the snake_case enum value.
|
|
expected_type_id = artifact_type.replace("-", "_")
|
|
for art in artifacts:
|
|
assert art.get("type_id") == expected_type_id, (
|
|
f"Parsed type_id {art.get('type_id')!r} does not match "
|
|
f"filter {artifact_type!r} (cassette {cassette})"
|
|
)
|
|
|
|
def test_artifact_list_type_mind_map_interactive(self, runner, mock_auth_for_vcr, mock_context):
|
|
"""`artifact list --type mind-map` surfaces an interactive (studio-artifact) map.
|
|
|
|
Reuses the interactive recording (``mind_maps_interactive.yaml``,
|
|
``ARTIFACT_NOTEBOOK_ID``). Stays on the table renderer (no ``--json``) so
|
|
it needs only ``LIST_ARTIFACTS`` + ``GET_NOTES_AND_MIND_MAPS``, both
|
|
present in the cassette — proving the type-4/variant-4 map is recognized
|
|
end-to-end through the CLI (#1256).
|
|
|
|
Re-record-safe assertion: the rendered table must carry the mind-map
|
|
**type display** (``get_artifact_type_display`` → ``Mind Map``), which
|
|
the renderer only emits for a row whose parsed kind is
|
|
``ArtifactType.MIND_MAP``. That proves the type-4/variant-4 artifact was
|
|
recognized as a mind map and survived the ``--type mind-map`` filter,
|
|
without pinning the recorded artifact id/title (which change on a
|
|
re-record against a different notebook). The empty-state path prints
|
|
``No mind-map artifacts found`` instead, so the marker also proves the
|
|
filter returned a non-empty row.
|
|
"""
|
|
nb = ARTIFACT_NOTEBOOK_ID
|
|
with notebooklm_vcr.use_cassette("mind_maps_interactive.yaml", allow_playback_repeats=True):
|
|
result = runner.invoke(cli, ["artifact", "list", "--type", "mind-map", "-n", nb])
|
|
assert_command_success(result)
|
|
assert "Mind Map" in result.output, (
|
|
"Expected the rendered table to carry the mind-map type display, "
|
|
"proving the type-4/variant-4 interactive map was recognized and "
|
|
f"passed the --type mind-map filter; output was:\n{result.output}"
|
|
)
|
|
assert "No mind-map artifacts found" not in result.output
|
|
|
|
|
|
class TestArtifactSuggestionsCommand:
|
|
"""Test 'notebooklm artifact suggestions' command."""
|
|
|
|
@notebooklm_vcr.use_cassette("artifacts_suggest_reports.yaml")
|
|
def test_artifact_suggestions(self, runner, mock_auth_for_vcr, mock_context):
|
|
"""Get artifact suggestions works with real client."""
|
|
result = runner.invoke(cli, ["artifact", "suggestions"])
|
|
assert_command_success(result)
|