# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. """Contracts for the update popup's release-notes preview. The popup renders CHANGELOG.md notes for the exact version it is offering. The risk this file guards is showing notes from a different release: a near-miss lookup must return nothing rather than the newest section it can find.""" from __future__ import annotations import http.server import json import os import re import shutil import subprocess import sys import threading import time from pathlib import Path import pytest REPO = Path(__file__).resolve().parents[2] BACKEND = REPO / "studio/backend" FRONTEND = REPO / "studio/frontend/src" CHANGELOG = REPO / "CHANGELOG.md" PANEL = FRONTEND / "components/update/release-notes-panel.tsx" NOTES_HOOK = FRONTEND / "hooks/use-release-notes.ts" PREVIEW = FRONTEND / "lib/release-notes-preview.ts" CODE_SPANS = FRONTEND / "lib/markdown-code-spans.ts" LINKS = FRONTEND / "lib/changelog-links.ts" LIST_COLUMNS = FRONTEND / "lib/markdown-list-columns.ts" INLINE_COMMENTS = FRONTEND / "lib/markdown-inline-comments.ts" WEB_BANNER = FRONTEND / "components/web/update-banner.tsx" TAURI_BANNER = FRONTEND / "components/tauri/update-banner.tsx" # The scanners are the frontend half of the contract the parser implements, so they are # run rather than read. Node strips the types and nothing imports a package: no install. _TS_ALIAS = re.compile(r'"@/lib/([a-z-]+)"') _TS_RUNNER = """ import { resolveChangelogLinks } from "./changelog-links.ts"; import { releaseNotesPreview } from "./release-notes-preview.ts"; const chunks: Buffer[] = []; process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk)); process.stdin.on("end", () => { const markdown = Buffer.concat(chunks).toString("utf8"); const result = process.argv[2] === "links" ? resolveChangelogLinks(markdown) : releaseNotesPreview(markdown); process.stdout.write(JSON.stringify(result)); }); """ SAMPLE = """# Changelog Intro prose that belongs to no release. ## Format ```md ## 9999.9.9 - fenced sample, not a real section ``` ## Unreleased - staged note ## 2026.7.6 - 2026-07-22 ### What's Changed - newer thing ## 2026.7.5 ### What's Changed - older thing """ @pytest.fixture(scope = "module") def changelog_module(): sys.path.insert(0, str(BACKEND)) try: from utils import changelog finally: sys.path.pop(0) changelog.reset_changelog_cache() yield changelog changelog.reset_changelog_cache() @pytest.fixture def isolated_changelog(changelog_module, tmp_path, monkeypatch): """Point the module at a temp file and away from the network.""" monkeypatch.setenv(changelog_module.DISABLE_ENV_VAR, "1") path = tmp_path / "CHANGELOG.md" path.write_text(SAMPLE, encoding = "utf-8") monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(path)) changelog_module.reset_changelog_cache() yield changelog_module changelog_module.reset_changelog_cache() def test_only_real_release_headings_become_sections(changelog_module): versions = [entry.version for entry in changelog_module.parse_changelog(SAMPLE)] # "Format"/"Unreleased" are not versions, and 9999.9.9 is fenced sample. assert versions == ["2026.7.6", "2026.7.5"] def test_section_body_stops_at_the_next_release(changelog_module): entry = changelog_module.find_release_notes(SAMPLE, "2026.7.6") assert entry is not None assert "newer thing" in entry.body assert "older thing" not in entry.body def test_unknown_version_returns_no_notes_instead_of_a_nearby_release(changelog_module): assert changelog_module.find_release_notes(SAMPLE, "2026.7.7") is None assert changelog_module.find_release_notes(SAMPLE, "2026.7") is None def test_version_equality_is_normalized_not_fuzzy(changelog_module): entry = changelog_module.find_release_notes(SAMPLE, "2026.07.6") assert entry is not None and entry.version == "2026.7.6" def test_response_reports_no_match_without_markdown(isolated_changelog): payload = isolated_changelog.get_release_notes("2026.7.7") assert payload["matched"] is False assert payload["markdown"] is None assert payload["version"] == "2026.7.7" # The UI still needs somewhere to send the user. assert payload["release_notes_url"] def test_response_matches_local_changelog_when_offline(isolated_changelog): payload = isolated_changelog.get_release_notes("2026.7.6") assert payload["matched"] is True assert payload["source"] == "local" assert "newer thing" in payload["markdown"] def test_unsupported_version_query_is_rejected(isolated_changelog): assert isolated_changelog.is_supported_version_query("2026.7.6") is True for bad in ("../etc/passwd", "2026.7.6 OR 1", "", "a" * 80): assert isolated_changelog.is_supported_version_query(bad) is False assert isolated_changelog.get_release_notes("../etc/passwd")["matched"] is False def test_remote_changelog_wins_over_bundled_copy(changelog_module, tmp_path, monkeypatch): """The offered version is newer than the installed checkout, so the repo copy has to be able to describe versions the local file has never heard of.""" monkeypatch.delenv(changelog_module.DISABLE_ENV_VAR, raising = False) local = tmp_path / "CHANGELOG.md" local.write_text(SAMPLE, encoding = "utf-8") monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) remote_body = "# Changelog\n\n## 2026.8.0\n\n- shipped after this install\n" class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 - stdlib naming payload = remote_body.encode("utf-8") self.send_response(200) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def log_message(self, *_args): pass server = http.server.HTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target = server.serve_forever, daemon = True) thread.start() try: monkeypatch.setenv( changelog_module.CHANGELOG_URL_ENV_VAR, f"http://127.0.0.1:{server.server_port}/CHANGELOG.md", ) changelog_module.reset_changelog_cache() payload = changelog_module.get_release_notes("2026.8.0") assert payload["matched"] is True assert payload["source"] == "remote" assert "shipped after this install" in payload["markdown"] finally: server.shutdown() server.server_close() changelog_module.reset_changelog_cache() def test_repo_changelog_exists_and_parses(changelog_module): assert CHANGELOG.is_file(), "CHANGELOG.md is the editable source of release notes" entries = changelog_module.parse_changelog(CHANGELOG.read_text(encoding = "utf-8")) assert entries, "CHANGELOG.md needs at least one `## ` section" def test_longer_outer_fence_does_not_leak_a_fake_section(changelog_module): """A ``` sample inside a ```` block must not close the block and let the sample's heading be indexed as a real release.""" text = "## 1.0\n\n````md\n```\n## 9.9.9\n```\n````\n\n- real note\n" assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] assert changelog_module.find_release_notes(text, "9.9.9") is None def test_tilde_fence_is_not_closed_by_backticks(changelog_module): text = "## 1.0\n\n~~~\n```\n## 9.9.9\n~~~\n\n- real\n" assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] def test_utf8_bom_does_not_hide_the_first_section(changelog_module): """Editors on Windows can leave a BOM on the first line.""" assert [e.version for e in changelog_module.parse_changelog("\ufeff## 1.0\n\n- x\n")] == ["1.0"] @pytest.mark.parametrize("newline", ["\r\n", "\r"]) def test_non_unix_line_endings(changelog_module, newline): text = f"## 1.0{newline}{newline}- windows note{newline}" entry = changelog_module.find_release_notes(text, "1.0") assert entry is not None and "windows note" in entry.body assert "\r" not in entry.body def test_closing_fence_must_carry_nothing_after_it(changelog_module): """CommonMark: a closer is the delimiter plus whitespace only. A ```` line with trailing text inside a ```` block is content, not the end.""" text = "## 1.0\n\n````md\n```` not a closer\n## 9.9.9\n````\n\n- real\n" assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] # An opening fence may still carry an info string. info = "## 1.0\n\n```python\n## 9.9.9\n```\n\n- real\n" assert [e.version for e in changelog_module.parse_changelog(info)] == ["1.0"] @pytest.mark.parametrize( "text", [ "## 1.0\n\n- real\n\n\n", "## 1.0\n\n- real\n\n\n", ], ) def test_commented_out_sections_are_not_releases(changelog_module, text): """Markdown does not render them, so they are not published notes.""" assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] assert changelog_module.find_release_notes(text, "9.9.9") is None def test_repo_root_changelog_is_preferred_over_the_build_snapshot(changelog_module): """The build backend writes studio/CHANGELOG.md; the root file must win.""" # Resolved paths, not name suffixes: a checkout may be renamed and Windows uses "\". paths = [Path(p).resolve() for p in changelog_module._local_changelog_candidates()] root = paths.index((REPO / changelog_module.CHANGELOG_FILENAME).resolve()) packaged = paths.index((REPO / "studio" / changelog_module.CHANGELOG_FILENAME).resolve()) assert root < packaged build = (REPO / "build.sh").read_text(encoding = "utf-8") assert "rm -f studio/CHANGELOG.md" in build, "snapshot must not linger after a build" def test_preview_keeps_identifier_underscores(): """UNSLOTH_DISABLE_UPDATE_CHECK must not render as UNSLOTHDISABLEUPDATECHECK.""" src = PREVIEW.read_text(encoding = "utf-8") assert "BOLD_UNDERSCORE" in src and "ITALIC_UNDERSCORE" in src assert "parkCodeSpans" in src, "code spans are parked so their underscores survive" assert "const EMPHASIS" not in src, "the blanket emphasis strip is gone" def test_panel_prefers_the_callers_release_url(): """The API only returns the generic changelog; the desktop banner passes the exact release page for the version being offered.""" src = PANEL.read_text(encoding = "utf-8") assert "releaseNotesUrl ?? notes?.releaseNotesUrl" in src def test_remote_failure_is_reported_so_the_ui_can_retry(changelog_module, tmp_path, monkeypatch): """A bundled changelog cannot know a version newer than the install, so a failed remote lookup must not read as "no notes were published".""" monkeypatch.delenv(changelog_module.DISABLE_ENV_VAR, raising = False) local = tmp_path / "CHANGELOG.md" local.write_text("## 1.0\n\n- old release\n", encoding = "utf-8") monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) # Port 9 (discard) refuses fast, standing in for an unreachable host. monkeypatch.setenv(changelog_module.CHANGELOG_URL_ENV_VAR, "http://127.0.0.1:9/CHANGELOG.md") changelog_module.reset_changelog_cache() try: payload = changelog_module.get_release_notes("2.0") assert payload["matched"] is False assert payload["error"], "remote failure must reach the UI" finally: changelog_module.reset_changelog_cache() def test_preview_keeps_comparison_operators(): """ "Support Python <3.15 and >3.9" must not lose its operators to the tag strip, which would turn it into "Support Python 3.9".""" src = PREVIEW.read_text(encoding = "utf-8") assert "/<\\/?[a-zA-Z][^>]*>/g" in src, "tag strip must require a name character" def test_preview_hides_commented_out_notes(): """Unpublished notes inside are not rendered, so not previewed.""" src = PREVIEW.read_text(encoding = "utf-8") assert "stripCommentSpans" in src and "COMMENT_OPEN" in src def test_hook_treats_a_reported_failure_as_retryable(): src = NOTES_HOOK.read_text(encoding = "utf-8") assert "next.error !== null" in src def test_comment_delimiter_in_inline_code_is_literal(changelog_module): """A note documenting ` render as nothing, so the popup must say no notes were published rather than show an empty surface.""" monkeypatch.setenv(changelog_module.DISABLE_ENV_VAR, "1") local = tmp_path / "CHANGELOG.md" local.write_text("## 2.0\n\n\n\n## 1.0\n\n- shipped\n", encoding = "utf-8") monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) changelog_module.reset_changelog_cache() try: staged = changelog_module.get_release_notes("2.0") assert staged["matched"] is False and staged["markdown"] is None assert changelog_module.get_release_notes("1.0")["matched"] is True finally: changelog_module.reset_changelog_cache() @pytest.mark.parametrize( "body,visible", [ ("- note", True), ("", False), ("```\n```", True), ("
\n
", True), (" ", False), ], ) def test_visibility_check_only_hides_comments(changelog_module, body, visible): assert changelog_module._renders_visibly(body) is visible @pytest.mark.parametrize( "block", [ "", "", "", ], ) def test_processing_instructions_and_declarations_are_literal(changelog_module, block): """Raw block types 3 to 5 render literally, like
, so a heading inside
    one is a sample and not a release."""
    text = f"## 1.0\n\n{block}\n\n- real note\n"
    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
    assert "real note" in changelog_module.find_release_notes(text, "1.0").body


def test_headings_need_a_space_or_tab_after_the_hashes(changelog_module):
    """A non-breaking space pasted from rich text renders as ordinary text, so
    the line must not end the release above it."""
    text = "## 1.0\n\n- real note\n\n## 9.9.9\n\n- not a release\n"
    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
    assert changelog_module.find_release_notes(text, "9.9.9") is None
    # A tab is valid and still opens a heading.
    tabbed = "## 1.0\n\n- one\n\n##\t2.0\n\n- two\n"
    assert [e.version for e in changelog_module.parse_changelog(tabbed)] == ["1.0", "2.0"]


def test_preview_skips_every_raw_block_form():
    """The extractor tracks the same block forms as the parser, so a sample
    bullet inside one cannot become the collapsed headline."""
    src = PREVIEW.read_text(encoding = "utf-8")
    assert "RAW_BLOCKS" in src
    assert "CDATA" in src and "[A-Za-z]" in src


@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER])
def test_expanded_popup_fits_a_short_viewport(banner):
    """A window under roughly 430px high used to push the card's title and
    dismiss control above the top of the screen."""
    panel = PANEL.read_text(encoding = "utf-8")
    # The notes region shrinks inside the capped card, so header and actions stay on screen.
    assert "min-h-0 flex-1" in panel, "notes height must follow the viewport"
    src = banner.read_text(encoding = "utf-8")
    assert "max-h-[calc(100dvh_-_2rem)]" in src, "card is the backstop on tiny viewports"


def test_relative_changelog_links_point_at_the_repository():
    """CHANGELOG.md links are repository-relative. Rendered as-is they resolve
    against Studio's origin, so the renderer blocks them."""
    src = LINKS.read_text(encoding = "utf-8")
    assert "https://github.com/unslothai/unsloth/blob/main/" in src
    assert "https://raw.githubusercontent.com/unslothai/unsloth/main/" in src
    # Absolute targets, fragments, fenced code and code spans stay untouched.
    assert "ABSOLUTE" in src and "codeSpans" in src and "FENCE" in src
    panel = PANEL.read_text(encoding = "utf-8")
    assert "resolveChangelogLinks" in panel


@pytest.mark.parametrize("query", ["latest", "main", "not-a-version", "abc"])
def test_unparseable_versions_are_rejected(changelog_module, query):
    """Sections are indexed only when their version parses, so a query that
    cannot parse can never match and is a bad request, not an empty result."""
    assert changelog_module.is_supported_version_query(query) is False


@pytest.mark.parametrize("query", ["2026.7.5", "v2026.7.5", "2026.07.5", "1.0.0rc1"])
def test_real_versions_are_still_accepted(changelog_module, query):
    assert changelog_module.is_supported_version_query(query) is True


def test_reference_style_images_resolve_to_the_raw_host():
    """`![alt][arch]` with `[arch]: docs/arch.png` needs the raw file: the blob
    URL is an HTML page, so the image would not load."""
    src = LINKS.read_text(encoding = "utf-8")
    assert "IMAGE_REFERENCE" in src
    assert "imageLabels" in src


def test_collapsed_notes_surface_is_hidden_when_nothing_previews():
    """Notes that are only a fenced command block preview as nothing, and an
    empty muted strip is worse than no strip."""
    src = PANEL.read_text(encoding = "utf-8")
    assert "preview?.items.length === 0" in src


def test_a_fence_closer_accepts_only_spaces_and_tabs(changelog_module):
    """A delimiter followed by a non-breaking space is code content, so it must
    not close the block and let a sample heading through."""
    text = "## 1.0\n\n```\n```\u00a0\n## 9.9.9\n```\n\n- real note\n"
    assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]
    plain = "## 1.0\n\n```\nx\n```\t\n\n## 2.0\n\n- two\n"
    assert [e.version for e in changelog_module.parse_changelog(plain)] == ["1.0", "2.0"]
    # The same rule in both frontend scanners.
    for source in (PREVIEW, LINKS):
        assert "/[^ \\t]/" in source.read_text(encoding = "utf-8")


def test_code_spans_close_on_a_run_of_equal_length():
    """`a``b [x](y.md)` is one code span, so the link inside it is literal."""
    src = CODE_SPANS.read_text(encoding = "utf-8")
    assert "candidate === ticks" in src, "closer length must match the opener"
    # Shared, so the preview and the link resolver cannot drift apart.
    assert "markdown-code-spans" in PREVIEW.read_text(encoding = "utf-8")
    assert "markdown-code-spans" in LINKS.read_text(encoding = "utf-8")


def test_preview_decodes_entities_like_the_renderer():
    """Streamdown renders `AT&T` as AT&T, so the collapsed preview must
    not show the raw entity."""
    src = PREVIEW.read_text(encoding = "utf-8")
    assert "NAMED_ENTITIES" in src and "decodeEntity" in src
    # Decoded before code spans are restored, so code keeps the literal text.
    assert src.index(".replace(ENTITY, decodeEntity)") < src.index(".replace(PARKED")


def test_release_notes_request_refreshes_an_expired_token():
    """A direct fetch cannot recover from a 401; authFetch refreshes first."""
    src = NOTES_HOOK.read_text(encoding = "utf-8")
    assert "authFetch(" in src
    assert "getAuthToken" not in src


def test_preview_handles_the_desktop_updater_line_endings():
    """The updater body arrives with CRLF, which used to hide fences from the
    extractor and promote a code sample to a headline."""
    src = PREVIEW.read_text(encoding = "utf-8")
    assert "LINE_ENDINGS" in src
    assert "LINE_ENDINGS" in LINKS.read_text(encoding = "utf-8")


def test_preview_renders_reference_links_as_text():
    """`[text][label]` and `![alt][label]` render as a link and an image, so
    the preview must not show their raw markup."""
    src = PREVIEW.read_text(encoding = "utf-8")
    assert "LINK_REFERENCE" in src and "IMAGE_REFERENCE" in src
    # A definition line renders as nothing, so it is not a preview item.
    assert "DEFINITION" in src


def test_preview_treats_escaped_punctuation_as_literal():
    """`\\*not italic\\*` keeps its stars and an escaped backtick does not open
    a code span."""
    assert "ESCAPE" in PREVIEW.read_text(encoding = "utf-8")
    assert "escaped(" in CODE_SPANS.read_text(encoding = "utf-8")


def test_link_resolver_skips_every_code_form():
    """Indented code and code spans crossing a line render as code, so their
    contents must not be rewritten."""
    src = LINKS.read_text(encoding = "utf-8")
    assert "INDENTED_CODE" in src
    # Spans are scanned over the whole document, not line by line.
    assert "codeSpans(masked)" in src
    # A definition cannot interrupt a paragraph.
    assert "definition.has(index)" in src


def test_badge_links_resolve_both_targets():
    """`[![alt](img)](link)` is the badge idiom: the outer link used to stay
    relative because the label was not allowed to nest."""
    assert "NESTED_LABEL" in LINKS.read_text(encoding = "utf-8")


def test_in_flight_requests_are_identified_not_just_versioned():
    """Two requests for the same version could resolve out of order and leave
    the panel showing the older result."""
    assert "requestIdRef" in NOTES_HOOK.read_text(encoding = "utf-8")


def test_notes_repair_the_shared_previews_width_reset():
    """MarkdownPreview clears max-width on every descendant, so a wide image
    and the renderer's own link dialog escape the card."""
    src = PANEL.read_text(encoding = "utf-8")
    assert "[&_img]:max-w-full" in src
    assert "[&_[data-streamdown=link-safety-modal]>*]:max-w-md" in src


@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER])
def test_only_the_notes_region_scrolls(banner):
    """The dismiss control sits inside the card, so scrolling the card itself
    carried it off screen on a short viewport."""
    src = banner.read_text(encoding = "utf-8")
    assert "flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden" in src
    assert 'className="min-h-0 flex-1"' in src
    panel = PANEL.read_text(encoding = "utf-8")
    assert "max-h-64 min-h-0 flex-1 overflow-y-auto" in panel


def test_a_comment_marker_in_prose_cannot_swallow_later_releases(changelog_module):
    """A note that mentions `\n\n- note\n"
    assert [e.version for e in changelog_module.parse_changelog(hidden)] == ["2.0"]


def test_unmatched_backtick_runs_stay_linear(changelog_module):
    """Rescanning the suffix for every opener was quadratic: a line of runs of
    1, 2, 3 ... backticks, none of which ever closes, took 7.7s at 321 KB and
    is reparsed on every popup request, so one malformed remote changelog could
    tie up backend workers."""
    line = "".join("`" * (i + 1) + "x" for i in range(800))
    assert len(line) > 300_000
    started = time.monotonic()
    assert changelog_module._code_span_ranges(line) == []
    assert time.monotonic() - started < 2.0


def test_a_base_exception_releases_the_single_flight_flag(changelog_module, monkeypatch):
    """The flag was cleared only after `except Exception`, so a BaseException
    (KeyboardInterrupt, SystemExit, CancelledError) stranded it and every later
    caller then waited out the full deadline for the life of the process."""
    changelog_module.reset_changelog_cache()

    def explode():
        raise KeyboardInterrupt

    monkeypatch.setattr(changelog_module, "_fetch_remote_changelog", explode)
    with pytest.raises(KeyboardInterrupt):
        changelog_module.get_remote_changelog()
    assert changelog_module._remote_fetching is False
    changelog_module.reset_changelog_cache()


@pytest.mark.parametrize("marker", ["", ""])
def test_an_empty_comment_does_not_swallow_later_releases(changelog_module, marker):
    """`` and `` are complete comments in CommonMark: the closer
    overlaps the opener. Searching for `-->` past the opener missed them, so an
    empty comment used as a section marker hid every release below it."""
    text = f"## 2.0\n\n- new stuff\n\n{marker}\n\n## 1.0\n\n- old stuff\n"
    assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"]
    assert changelog_module.find_release_notes(text, "1.0") is not None
    assert "old stuff" not in changelog_module.find_release_notes(text, "2.0").body
    # The frontend scanner has to agree, or the preview and the body disagree.
    assert "!line.includes(COMMENT_CLOSE)" in PREVIEW.read_text(encoding = "utf-8")


def test_an_unterminated_comment_still_hides_the_rest(changelog_module):
    """The fix must not turn every `` or `
` is not a release.""" for text in ( "## 1.0\n\n## 9.9.9\n\n- note\n", "## 1.0\n\n
\nx\n
## 9.9.9\n\n- note\n", ): assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] def test_an_exact_heading_is_never_shadowed(changelog_module): """PEP 440 says 1.0 == 1.0.0, so the normalised match used to win even when the file had a section spelled exactly as asked.""" text = "## 1.0.0\n\n- padded\n\n## 1.0\n\n- exact\n" assert changelog_module.find_release_notes(text, "1.0").body == "- exact" assert changelog_module.find_release_notes(text, "1.0.0").body == "- padded" # Normalised matching still applies when there is no exact heading. assert changelog_module.find_release_notes("## 2026.7.6\n\n- x\n", "2026.07.6") is not None def test_setext_headings_are_release_boundaries(changelog_module): """A version over a line of dashes is the same heading in setext form.""" text = "2.0\n---\n\n- new\n\n1.0\n---\n\n- old\n" assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] assert changelog_module.find_release_notes(text, "2.0").body == "- new" # A rule between sections is still a rule, and a setext h1 is not a release. assert [ e.version for e in changelog_module.parse_changelog("## 2.0\n\n- a\n\n---\n\n## 1.0\n\n- b\n") ] == ["2.0", "1.0"] def test_a_long_backtick_run_does_not_stall_the_parser(changelog_module): """The code-span guard used to backtrack: 20k backticks took over a minute and every request re-parsed the file.""" import time text = "## 1.0\n\n- " + "`" * 20_000 + " " * 16_000 assert len(line) < changelog_module.CHANGELOG_MAX_BYTES started = time.monotonic() visible, in_comment = changelog_module._strip_comments(line, False, False) elapsed = time.monotonic() - started # Roughly 40ms scanning forward against roughly 11s restarting each time. assert elapsed < 2.0, f"comment stripping took {elapsed:.1f}s" # Same result as before: the spans survive and the comments are gone. assert in_comment is False assert "`\n- See [docs](docs/a.md)\n") assert repo in spanned # A comment starting a line is a block: it hides down to the closer's line, that line included. block = run_scanner("links", "\n") assert repo not in block closer = run_scanner("links", " See [docs](docs/a.md)\n") assert repo not in closer def test_a_bare_level_two_marker_ends_the_release(changelog_module, run_scanner): """An ATX heading's opening sequence may be followed by the end of the line (spec 0.31.2 section 4.2), so a bare `##` is an empty level-two heading. The scanners required whitespace after the hashes, so everything below such a line stayed inside the release above it and the popup showed unrelated notes under that version.""" text = "## 2.0\n\n- new thing\n\n##\n\n- SECRET: not part of 2.0\n" entry = changelog_module.find_release_notes(text, "2.0") assert "new thing" in entry.body assert "SECRET" not in entry.body # An empty heading has no version, so it ends a release without indexing one. assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0"] # Prose still needs a space or a tab: `##x` is a paragraph, not a heading. prose = "## 2.0\n\n- new thing\n\n##x\n\n- still 2.0\n" assert "still 2.0" in changelog_module.find_release_notes(prose, "2.0").body # The preview agrees: an empty heading renders as nothing, so it ends the bullet. preview = run_scanner("preview", "- new thing\n##\nUnrelated scratch notes\n") assert preview_leads(preview) == ["new thing"] def test_a_comment_between_bullets_closes_the_list(changelog_module, run_scanner): """A comment is an HTML block (spec 0.31.2 section 4.6, type 2), so one written at the margin under a bullet is not indented enough to continue that item and closes the list. The scanners blanked the line before list tracking saw it, which reads as a blank line and leaves the item open, so the release heading below it looked like nested item content and the new release was merged into the one above.""" text = "## 1.0\n\n- old item\n\n ## 2.0\n\n- new item\n" assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] assert "new item" not in changelog_module.find_release_notes(text, "1.0").body assert "new item" in changelog_module.find_release_notes(text, "2.0").body # At the item's content column the comment stays inside it, so the heading under it is nested. nested = "## 1.0\n\n- old item\n \n ## 2.0\n\n- new item\n" assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] # The link resolver reads the same column: list closed, four spaces is code, left untouched. code = run_scanner("links", "- old item\n\n [guide](docs/a.md)\n") assert "[guide](docs/a.md)" in code and "github.com" not in code # Inside the item those four spaces are two columns in, so it is prose and the link resolves. prose = run_scanner("links", "- old item\n \n [guide](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in prose # The preview agrees: the fence is indented code, not a fence swallowing the bullet below. preview = run_scanner( "preview", "- Details:\n\n ```\n - hidden sample\n- Real second item\n", ) assert preview_leads(preview) == ["Details:", "Real second item"] def test_a_parenthesised_link_destination_still_resolves(run_scanner): """A destination may hold parentheses while they balance (spec 0.31.2 section 6.3), so `[x]((draft).md)` points at `(draft).md`. The resolver's destination expression stopped at the first paren, matched an empty destination and left the markdown alone, so the link resolved against Studio's own origin instead of the repository.""" leading = run_scanner("links", "[details]((draft).md)\n") assert "https://github.com/unslothai/unsloth/blob/main/(draft).md" in leading # An image resolves against the raw host the same way. image = run_scanner("links", "![shield]((badge).png)\n") assert "https://raw.githubusercontent.com/unslothai/unsloth/main/(badge).png" in image # A pair in the middle of a path balances too. middle = run_scanner("links", "[api](docs/(v2)/api.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/(v2)/api.md" in middle # An unbalanced paren makes the destination invalid, so `[x](a(b.md)` is plain text, not a link. unbalanced = run_scanner("links", "[x](a(b.md)\n") assert unbalanced == "[x](a(b.md)\n" # One more closer balances the pair, and then it is a link again. closed = run_scanner("links", "[x](a(b.md))\n") assert "https://github.com/unslothai/unsloth/blob/main/a(b.md)" in closed # Pairs nest, and one level was all the expression allowed, so a path with two stayed relative. nested = run_scanner("links", "[x](((draft)).md)\n") assert "https://github.com/unslothai/unsloth/blob/main/((draft)).md" in nested deep = run_scanner("links", "![shot](((((v2))))).png)\n") assert "https://raw.githubusercontent.com/unslothai/unsloth/main/((((v2))))" in deep # The closer must still be there: an unbalanced run below a nested pair is not a link. across = run_scanner("links", "[x](((a).md\n[y](docs/y.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/y.md" in across assert "[x](((a).md" in across def test_a_fence_inside_a_container_still_hides_its_sample(run_scanner): """A fence is measured from its container and not from the margin (spec 0.31.2 section 4.5), so `> ~~~` and a fence three columns under a nested bullet open one. Reading the margin instead never saw them, so the sample inside was treated as prose and a relative link written in a code block was rewritten into the text the reader sees verbatim.""" quoted = run_scanner("links", "> ~~~\n> [guide](docs/a.md)\n> ~~~\n") assert "[guide](docs/a.md)" in quoted and "github.com" not in quoted nested = run_scanner("links", "- a\n - b\n ~~~\n [x](docs/x.md)\n ~~~\n") assert "[x](docs/x.md)" in nested and "github.com" not in nested # A longer closer is still a closer, so the pair is not something a code span hid. uneven = run_scanner("links", "> ```\n> [guide](docs/a.md)\n> ````\n") assert "[guide](docs/a.md)" in uneven and "github.com" not in uneven # The fence ends with its container: a line outside the quote, or left of the item, is Markdown. left = run_scanner("links", "> ~~~\n[guide](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in left dedented = run_scanner("links", "- a\n ~~~\n[guide](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in dedented # A document-level fence owns the quoted lines below, so the marker does not undo it. document = run_scanner("links", "~~~\n> [guide](docs/a.md)\n~~~\n") assert "[guide](docs/a.md)" in document and "github.com" not in document # Four columns past the item's content column it is indented code, not a fence: still literal. code = run_scanner("links", "- Details:\n\n ~~~\n [guide](docs/a.md)\n") assert "[guide](docs/a.md)" in code and "github.com" not in code def test_an_html_block_inside_a_container_is_literal_too(run_scanner): """Type 1 and type 6 blocks are measured from their container the same way, so a `
` under a nested bullet and a `
` inside a quote both
    show their contents verbatim. Missing the opener treated the body as
    Markdown and rewrote the literal examples in it."""
    nested = run_scanner("links", "- a\n  - b\n    
\n [x](docs/x.md)\n
\n") assert "[x](docs/x.md)" in nested and "github.com" not in nested quoted = run_scanner("links", ">
\n> [x](docs/x.md)\n> 
\n") assert "[x](docs/x.md)" in quoted and "github.com" not in quoted # The block ends with its container, so a line dedented out of the item is Markdown again. dedented = run_scanner("links", "- a\n - b\n
\n[x](docs/x.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in dedented # Inside a quote a bare marker holds nothing, the blank line that ends a type 6 block. blank = run_scanner("links", ">
\n>\n> [x](docs/x.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in blank def test_an_underline_left_of_an_item_is_lazy_text_of_it(changelog_module, run_scanner): """A setext underline may never be a lazy continuation line (spec 0.31.2 section 4.3), so `===` written left of an open list item is read as more of the item's paragraph rather than as a block that closes it. Rejecting every underline-shaped line ended the list there, which promoted the nested "## 2.0" below it to a document-level heading and indexed a release the renderer never shows.""" nested = "## 1.0\n- old note\n===\n ## 2.0\n- new\n" assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] # A row of dashes is a thematic break, closing the item, so the heading is the next release. broken = "## 1.0\n- old note\n---\n ## 2.0\n" assert [e.version for e in changelog_module.parse_changelog(broken)] == ["1.0", "2.0"] # With no paragraph above it the underline opens one, so the blank line closes the item. apart = "## 1.0\n- old note\n\n===\n ## 2.0\n" assert [e.version for e in changelog_module.parse_changelog(apart)] == ["1.0", "2.0"] # The link scanner keeps the item open, so the four-space line is a paragraph and resolves. resolved = run_scanner("links", "- Details:\n===\n\n [guide](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in resolved def test_a_quote_keeps_its_paragraph_to_itself(changelog_module, run_scanner): """Lazy continuation runs the other way too: a marker written outside a blockquote is not text of the quote's paragraph, so `2. item` under `> quote` opens a list even though an ordered marker past 1 may not interrupt a paragraph (spec 0.31.2 section 5.2). Lending the quote's paragraph to the document left the list closed, so the heading indented to the item's content column read as a release of its own.""" quoted = "## 1.0\n> quote\n2. item\n ## 2.0\n- new\n" assert [e.version for e in changelog_module.parse_changelog(quoted)] == ["1.0"] # A quote holding a heading leaves no paragraph, nor does an empty one, so the list opens. heading = "## 1.0\n> # inner\n2. item\n ## 2.0\n" assert [e.version for e in changelog_module.parse_changelog(heading)] == ["1.0"] # An unquoted line the quote's paragraph swallows keeps it open, the marker still outside. lazy = "## 1.0\n> quote\ntext\n2. item\n ## 2.0\n" assert [e.version for e in changelog_module.parse_changelog(lazy)] == ["1.0"] # Under an ordinary paragraph the marker is its text, so no list opens and the heading is real. prose = "## 1.0\nprose\n2. item\n ## 2.0\n" assert [e.version for e in changelog_module.parse_changelog(prose)] == ["1.0", "2.0"] # The preview reads the marker as a bullet for the same reason. assert preview_leads(run_scanner("preview", "> quote\n2. item\n")) == ["item"] def test_indented_code_before_an_ordered_marker_still_opens_a_list(changelog_module): """An indented code block ends at the first line that is not indented enough to continue it, and no paragraph is open for the marker below to continue, so `2. item` opens a list whatever its start number. Reading it as text of the code block instead would leave the list closed and index the heading at the item's content column as a release.""" joined = "## 1.0\n\n code\n2. item\n ## 2.0\n- new\n" assert [e.version for e in changelog_module.parse_changelog(joined)] == ["1.0"] # A blank line between the two changes nothing: the list opens either way. apart = "## 1.0\n\n code\n\n2. item\n ## 2.0\n- new\n" assert [e.version for e in changelog_module.parse_changelog(apart)] == ["1.0"] # Four columns past its container the marker is code, so no list opens and the heading stands. inside = "## 1.0\n\n code\n - item\n ## 2.0\n" assert [e.version for e in changelog_module.parse_changelog(inside)] == ["1.0", "2.0"] def test_a_fence_written_as_an_item_first_content_opens_in_that_item(run_scanner): """A block written straight after a list marker is the item's own first content, measured from the column that content starts (spec 0.31.2 section 5.2), so "- ```md" opens a fence. Reading the whole line instead never saw one, so the code sample below it was treated as prose: the resolver rewrote a destination the reader sees verbatim, and the preview offered the info string as a headline bullet.""" sample = run_scanner("links", "- ```md\n [example](docs/a.md)\n ```\n") assert "[example](docs/a.md)" in sample and "github.com" not in sample ordered = run_scanner("links", "1. ~~~\n [example](docs/a.md)\n ~~~\n") assert "[example](docs/a.md)" in ordered and "github.com" not in ordered # The preview agrees: an item of only a code block previews as nothing; the next is a bullet. preview = run_scanner("preview", "- ```md\n sample text\n ```\n- Added tests\n") assert preview_leads(preview) == ["Added tests"] # One column further in it is indented code inside the item, so the link is prose and resolves. padded = run_scanner("links", "- ```\n [example](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in padded # A marker the paragraph above swallows opens no item, so no fence: ordered items open at 1. lazy = run_scanner("links", "Intro.\n2. ```\n[guide](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in lazy def test_an_html_block_ends_with_the_item_it_was_written_in(changelog_module, run_scanner): """An HTML block holds no lazy continuation line, so one opened on a list item's continuation line ends where the item does, exactly as a fence there does. Ending it only on a blank line let it run past the item and swallow the next release heading, so those notes could never be found, and the collapsed preview lost every bullet below it.""" text = "## 1.0\n\n- item\n\n
\n## 2.0\n\n- new thing\n" assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] assert "new thing" in changelog_module.find_release_notes(text, "2.0").body # A raw block such as
 is scoped the same way.
    raw = "## 1.0\n\n- item\n\n  
\n## 2.0\n\n- new thing\n"
    assert [e.version for e in changelog_module.parse_changelog(raw)] == ["1.0", "2.0"]
    # At the item's content column the block holds the heading, which is nested and indexes nothing.
    nested = "## 1.0\n\n- item\n\n  
\n ## 2.0\n" assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] # The preview reads it the same way: the bullet below the block is a bullet. preview = run_scanner("preview", "- item\n\n
\n- Added tests\n") assert preview_leads(preview) == ["item", "Added tests"] # An opener straight after a marker opens in that item, so the dedented heading is a release. marked = "## 1.0\n\n-
\n## 2.0\n\n- new thing\n" assert [e.version for e in changelog_module.parse_changelog(marked)] == ["1.0", "2.0"] def test_a_comment_may_close_on_a_later_line_of_its_paragraph(run_scanner): """A comment written mid-sentence is inline raw HTML belonging to the paragraph around it, so its `-->` may arrive on a later line of that same paragraph and everything between renders as nothing. Ending the comment at its own line left a backtick inside it pairing with a real one below, which hid a following link from the resolver, and left the collapsed preview quoting text the popup body does not show.""" carried = run_scanner("links", "Note see [d](docs/a.md) and `x`\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in carried # Text inside the comment renders as nothing, so it is left alone. inside = run_scanner("links", "Note end\n") assert "[c](docs/c.md)" in inside and "github.com" not in inside # The preview hides it too, rather than quoting the comment at the reader. preview = run_scanner( "preview", "- Added X \n- Second\n" ) assert preview_leads(preview) == ["Added X", "Second"] # An opener cannot outlive its paragraph: with it closed the ` end [d](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in broken # A heading breaks into the paragraph, so it ends the comment's reach too. headed = run_scanner("links", "Note end [d](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in headed assert preview_leads(run_scanner("preview", "Note ` written on a line of its own, and a wrapped line may open with emphasis. The guard asking whether the closer is reachable read any line whose first character was punctuation as the start of a new block, so neither shape counted as more of the paragraph carrying the comment. The comment then never closed, and the collapsed popup showed the author's internal note to the user.""" closer = run_scanner( "preview", "- DoRA training is available in Studio. \n", ) assert preview_leads(closer) == ["DoRA training is available in Studio."] # A continuation may open with emphasis, which is text and not a block. starred = run_scanner( "preview", "- DoRA training is available. \n", ) assert preview_leads(starred) == ["DoRA training is available."] underscored = run_scanner( "preview", "- DoRA training is available. \n", ) assert preview_leads(underscored) == ["DoRA training is available."] # A real block still ends the paragraph, so the opener below one is text and hides nothing. broken = run_scanner("links", "Note [d](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in broken # So does a list item with content, which may interrupt a paragraph. item = run_scanner("links", "Note [d](docs/a.md)\n") assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in item def test_a_comment_written_as_an_item_first_content_is_a_block(changelog_module, run_scanner): """A comment is an HTML block (spec 0.31.2 section 4.6, type 2), so one written as a list item's first content opens inside that item, exactly as a fence written there does. The scanners looked for the opener at the margin of the line as written, so a marker in front of it hid the block: the resolver rewrote a destination inside raw HTML, which Streamdown then shows the reader as a literal URL, and the preview quoted the hidden note back at them as though the bullet were Markdown.""" item = run_scanner("links", "- AMD support, see [the guide](docs/amd.md)\n") assert item == "- AMD support, see [the guide](docs/amd.md)\n" # Every marker opens an item, and a nested one is still an item. for text in ( "* see [the guide](docs/amd.md)\n", "1. see [the guide](docs/amd.md)\n", "- outer\n - see [the guide](docs/amd.md)\n", ): assert "github.com" not in run_scanner("links", text) # The multiline form hides lines to the closer, as a comment at the item's content column did. multiline = run_scanner("links", "- \n") assert "[a](docs/x.md)" in multiline and "github.com" not in multiline # Still scoped to the item it was written in, so a line dedented out of it ends the block. dedented = run_scanner("links", "- hidden note\n- Real bullet\n") assert preview_leads(preview) == ["Real bullet"] # The parser agrees too: the item keeps its column, so a heading inside is nested, not indexed. text = "## 1.0\n\n-