diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0e743231..c8058fca2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -294,6 +294,10 @@ jobs: - name: Determine and output version run: | VERSION_WITH_V=$(bash scripts/package/getversion.sh) + # Strip the research tag suffix for filenames: the edition gets its + # own token (activitywatch[-tauri][-research]--...), so the + # version part stays a plain version. + VERSION_WITH_V="${VERSION_WITH_V%-research}" VERSION_NO_V="${VERSION_WITH_V#v}" echo "VERSION_WITH_V=${VERSION_WITH_V}" >> "$GITHUB_ENV" echo "========================================" @@ -305,6 +309,41 @@ jobs: echo "Version (no v): ${VERSION_NO_V}" echo "========================================" + - name: Verify aw-server-rust submodule version matches release tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + # Fail fast if the aw-server-rust submodule is pinned to an older + # release line than the one we're tagging. The Windows 0.14 Tauri + # bundle shipped with aw-server-rust v0.13.1 (activitywatch#1380); + # catch the same class of mismatch here before spending ~30 min on a + # full Tauri build. + # + # The bundled binary's version lives in + # aw-server-rust/aw-server/Cargo.toml (the workspace-level + # aw-server-rust/Cargo.toml has no [package].version field). + BUNDLED_VERSION=$(grep -m1 '^version = ' aw-server-rust/aw-server/Cargo.toml | sed -E 's/^version = "(.*)".*/\1/') + if [ -z "$BUNDLED_VERSION" ]; then + echo "ERROR: could not read aw-server version from aw-server-rust/aw-server/Cargo.toml" >&2 + exit 1 + fi + + AW_VERSION="${VERSION_NO_V}" # e.g. "0.14.0b3" + AW_MAJOR_MINOR=$(echo "$AW_VERSION" | cut -d'.' -f1-2) # "0.14" + AWS_MAJOR_MINOR=$(echo "$BUNDLED_VERSION" | cut -d'.' -f1-2) # "0.14" + + echo "AW release tag: ${AW_VERSION} (major.minor: ${AW_MAJOR_MINOR})" + echo "Bundled aw-server: ${BUNDLED_VERSION} (major.minor: ${AWS_MAJOR_MINOR})" + + if [ "$AW_MAJOR_MINOR" != "$AWS_MAJOR_MINOR" ]; then + echo "" + echo "ERROR: aw-server-rust major.minor (${AWS_MAJOR_MINOR}) does not match" + echo " AW release major.minor (${AW_MAJOR_MINOR})." + echo " The aw-server-rust submodule is stale for this tag." + echo " Update the submodule (cd aw-server-rust && git pull) and re-tag." + exit 1 + fi + echo "OK: aw-server version ${BUNDLED_VERSION} is consistent with AW release ${AW_VERSION}" + - name: Set up Python uses: actions/setup-python@v6 with: @@ -437,9 +476,9 @@ jobs: make dist/notarize fi - EDITION_SUFFIX="" - if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION_SUFFIX="-research-edition"; fi - mv dist/ActivityWatch.dmg dist/activitywatch-${VERSION_WITH_V}-macos-$(uname -m)${EDITION_SUFFIX}.dmg + EDITION="" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi + mv dist/ActivityWatch.dmg dist/activitywatch${EDITION}-${VERSION_WITH_V}-macos-$(uname -m).dmg env: APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} @@ -450,21 +489,11 @@ jobs: - name: Package AppImage if: startsWith(runner.os, 'linux') - run: | - ./scripts/package/package-appimage.sh - if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then - mv dist/activitywatch-linux-x86_64.AppImage \ - dist/activitywatch-linux-x86_64-research-edition.AppImage - fi + run: ./scripts/package/package-appimage.sh - name: Package deb if: startsWith(runner.os, 'linux') - run: | - ./scripts/package/package-deb.sh - if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then - deb="dist/activitywatch-${VERSION_WITH_V}-linux-x86_64.deb" - mv "$deb" "${deb%.deb}-research-edition.deb" - fi + run: ./scripts/package/package-deb.sh - name: Upload packages uses: actions/upload-artifact@v7 @@ -525,6 +554,10 @@ jobs: - name: Determine and output version run: | VERSION_WITH_V=$(bash scripts/package/getversion.sh) + # Strip the research tag suffix for filenames: the edition gets its + # own token (activitywatch[-tauri][-research]--...), so the + # version part stays a plain version. + VERSION_WITH_V="${VERSION_WITH_V%-research}" VERSION_NO_V="${VERSION_WITH_V#v}" echo "VERSION_WITH_V=${VERSION_WITH_V}" >> "$GITHUB_ENV" echo "========================================" @@ -536,6 +569,41 @@ jobs: echo "Version (no v): ${VERSION_NO_V}" echo "========================================" + - name: Verify aw-server-rust submodule version matches release tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + # Fail fast if the aw-server-rust submodule is pinned to an older + # release line than the one we're tagging. The Windows 0.14 Tauri + # bundle shipped with aw-server-rust v0.13.1 (activitywatch#1380); + # catch the same class of mismatch here before spending ~30 min on a + # full Tauri build. + # + # The bundled binary's version lives in + # aw-server-rust/aw-server/Cargo.toml (the workspace-level + # aw-server-rust/Cargo.toml has no [package].version field). + BUNDLED_VERSION=$(grep -m1 '^version = ' aw-server-rust/aw-server/Cargo.toml | sed -E 's/^version = "(.*)".*/\1/') + if [ -z "$BUNDLED_VERSION" ]; then + echo "ERROR: could not read aw-server version from aw-server-rust/aw-server/Cargo.toml" >&2 + exit 1 + fi + + AW_VERSION="${VERSION_NO_V}" # e.g. "0.14.0b3" + AW_MAJOR_MINOR=$(echo "$AW_VERSION" | cut -d'.' -f1-2) # "0.14" + AWS_MAJOR_MINOR=$(echo "$BUNDLED_VERSION" | cut -d'.' -f1-2) # "0.14" + + echo "AW release tag: ${AW_VERSION} (major.minor: ${AW_MAJOR_MINOR})" + echo "Bundled aw-server: ${BUNDLED_VERSION} (major.minor: ${AWS_MAJOR_MINOR})" + + if [ "$AW_MAJOR_MINOR" != "$AWS_MAJOR_MINOR" ]; then + echo "" + echo "ERROR: aw-server-rust major.minor (${AWS_MAJOR_MINOR}) does not match" + echo " AW release major.minor (${AW_MAJOR_MINOR})." + echo " The aw-server-rust submodule is stale for this tag." + echo " Update the submodule (cd aw-server-rust && git pull) and re-tag." + exit 1 + fi + echo "OK: aw-server version ${BUNDLED_VERSION} is consistent with AW release ${AW_VERSION}" + - name: Set up Python uses: actions/setup-python@v6 with: @@ -669,13 +737,13 @@ jobs: # `dist/activitywatch-*.*` upload pattern below. ARCH=$(uname -m) shopt -s nullglob - EDITION_SUFFIX="" - if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION_SUFFIX="-research-edition"; fi + EDITION="" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi for ext in AppImage deb rpm; do files=( dist/activitywatch/aw-tauri/*.${ext} ) case ${#files[@]} in 0) continue ;; - 1) cp -v "${files[0]}" "dist/activitywatch-tauri-${VERSION_WITH_V}-linux-${ARCH}${EDITION_SUFFIX}.${ext}" ;; + 1) cp -v "${files[0]}" "dist/activitywatch-tauri${EDITION}-${VERSION_WITH_V}-linux-${ARCH}.${ext}" ;; *) echo "ERROR: expected at most 1 .${ext} bundle, found ${#files[@]}" >&2; exit 1 ;; esac done @@ -700,9 +768,9 @@ jobs: make dist/notarize fi - EDITION_SUFFIX="" - if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION_SUFFIX="-research-edition"; fi - mv dist/ActivityWatch.dmg dist/activitywatch-tauri-${VERSION_WITH_V}-macos-$(uname -m)${EDITION_SUFFIX}.dmg + EDITION="" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi + mv dist/ActivityWatch.dmg dist/activitywatch-tauri${EDITION}-${VERSION_WITH_V}-macos-$(uname -m).dmg env: APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} @@ -784,7 +852,7 @@ jobs: mv release_notes_research.md release_notes.md # The generated Downloads section hardcodes standard-edition asset # filenames, which do not exist on research releases (research - # artifacts carry a research-edition suffix). Point at the asset + # artifacts carry a -research token). Point at the asset # list instead of linking to 404s. python3 - <<'PY' import re diff --git a/aw-watcher-window b/aw-watcher-window index 624823a7c..a7690ac00 160000 --- a/aw-watcher-window +++ b/aw-watcher-window @@ -1 +1 @@ -Subproject commit 624823a7cb00dc2c2e090bdae793bc97ba752d2c +Subproject commit a7690ac00393cf5b354f474dacf24a907d0ced72 diff --git a/scripts/package/package-all.sh b/scripts/package/package-all.sh index 0d1aee52c..eaecdbc7e 100755 --- a/scripts/package/package-all.sh +++ b/scripts/package/package-all.sh @@ -48,12 +48,17 @@ platform=$(get_platform) version=$(get_version) version_no_prefix=$(get_version_no_prefix) arch=$(get_arch) +# Research tags are suffixed (e.g. v0.14.0b3-research), but the edition +# belongs in its own filename token, not in the version part: +# activitywatch[-tauri][-research]---[-setup]. +version="${version%-research}" +version_no_prefix="${version_no_prefix%-research}" build_suffix="" if [[ $TAURI_BUILD == "true" ]]; then build_suffix="-tauri" fi if [[ $AW_RESEARCH_EDITION == "true" ]]; then - build_suffix="${build_suffix}-research-edition" + build_suffix="${build_suffix}-research" fi echo "========================================" diff --git a/scripts/package/package-appimage.sh b/scripts/package/package-appimage.sh index dd4ece935..b046c65d0 100755 --- a/scripts/package/package-appimage.sh +++ b/scripts/package/package-appimage.sh @@ -20,4 +20,11 @@ chmod a+x ./activitywatch/AppRun # build appimage ./linuxdeploy-x86_64.AppImage --appdir activitywatch --executable ./activitywatch/aw-qt --output appimage --desktop-file ./activitywatch/aw-qt.desktop --icon-file ./activitywatch/media/logo/logo.png --icon-filename activitywatch APPIMAGE_FILE=`ls -1 | grep AppImage| grep -i ActivityWatch` -cp -v $APPIMAGE_FILE ./dist/activitywatch-linux-x86_64.AppImage +# Deliberately unversioned: the AppImage has kept this exact name across +# releases so the stable download URL keeps working, e.g. +# https://github.com/ActivityWatch/activitywatch/releases/latest/download/activitywatch-linux-x86_64.AppImage +EDITION="" +if [[ $AW_RESEARCH_EDITION == "true" ]]; then + EDITION="-research" +fi +cp -v $APPIMAGE_FILE ./dist/activitywatch${EDITION}-linux-x86_64.AppImage diff --git a/scripts/package/package-deb.sh b/scripts/package/package-deb.sh index 493b97862..4e91bdb89 100755 --- a/scripts/package/package-deb.sh +++ b/scripts/package/package-deb.sh @@ -7,7 +7,14 @@ set -x SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VERSION="$("$SCRIPT_DIR/getversion.sh")" +# Strip the research tag suffix: the edition gets its own filename token, and +# a bare "-research" in the Debian version field would parse as a revision. +VERSION="${VERSION%-research}" VERSION_NUM="$(echo "$VERSION" | sed -e 's/^v//')" +EDITION="" +if [[ $AW_RESEARCH_EDITION == "true" ]]; then + EDITION="-research" +fi echo "Version (with v): $VERSION" echo "Version (without v): $VERSION_NUM" PKGDIR="activitywatch_$VERSION_NUM" @@ -51,4 +58,4 @@ sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/etc/xdg/autostart/ sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/usr/share/applications/ dpkg-deb --build $PKGDIR -sudo mv activitywatch_${VERSION_NUM}.deb dist/activitywatch-${VERSION}-linux-x86_64.deb +sudo mv activitywatch_${VERSION_NUM}.deb dist/activitywatch${EDITION}-${VERSION}-linux-x86_64.deb diff --git a/scripts/patch_research_edition_config.py b/scripts/patch_research_edition_config.py index e72f2dbd0..34d4974e8 100644 --- a/scripts/patch_research_edition_config.py +++ b/scripts/patch_research_edition_config.py @@ -1,16 +1,27 @@ #!/usr/bin/env python3 -"""Patch aw-watcher-window/config.py with Matthias's research edition category map. +"""Patch aw-watcher-window/config.py with Matthias's research edition category maps. Run as part of the CI build for research edition: python3 scripts/patch_research_edition_config.py -classify_title() in PR #130 checks each pattern against the URL first (when -available), then the window title. Ordering is critical: first match wins. -Sensitive exclusions must come first. More specific domains before general -(music.youtube.com before youtube.com). Video domains before News title keywords -(svtplay.se domain before the "svt" title keyword). +Two maps are injected: + +CATEGORY_MAP — browser URL/title substring matching: + classify_title() in PR #130 checks each pattern against the URL first (when + available), then the window title. Ordering is critical: first match wins. + Sensitive exclusions must come first. More specific domains before general + (music.youtube.com before youtube.com). Video domains before News title keywords + (svtplay.se domain before the "svt" title keyword). + +APP_CATEGORY_MAP — non-browser app-name → study category mapping: + classify_app() in PR #136 performs a case-insensitive exact lookup of the + app name. Non-browser apps are replaced by their study category; unmapped + apps become 'Excluded'. Ordering within this map is irrelevant (exact lookup). + Injection fails closed if the [aw-watcher-window.research_app_category_map] + section is absent, which means the submodule pin predates PR #136. """ import pathlib +import re import sys CONFIG_FILE = pathlib.Path( @@ -640,6 +651,96 @@ ("kagi", "Search & Navigation"), ] +# App-name → study category mapping for non-browser applications. +# Faithfully derived from Matthias Lehner's APP_TO_CATEGORY dict (classifier 2026-07-06). +# Keys are lowercase app names (exact match, case-insensitive at runtime). +# "Excluded" means the app is deliberately suppressed — not a lookup miss. +APP_CATEGORY_MAP: dict[str, str] = { + # AI chatbots & assistants + "chatgpt": "AI Chatbots & Assistants", + "chatgpt.exe": "AI Chatbots & Assistants", + "claude": "AI Chatbots & Assistants", + "claude.exe": "AI Chatbots & Assistants", + "gemini": "AI Chatbots & Assistants", + "microsoft copilot": "AI Chatbots & Assistants", + "copilot": "AI Chatbots & Assistants", + "perplexity": "AI Chatbots & Assistants", + "poe": "AI Chatbots & Assistants", + # Work & Productivity — Microsoft Office + "microsoft word": "Work & Productivity", + "word": "Work & Productivity", + "winword.exe": "Work & Productivity", + "microsoft excel": "Work & Productivity", + "excel": "Work & Productivity", + "excel.exe": "Work & Productivity", + "microsoft powerpoint": "Work & Productivity", + "powerpoint": "Work & Productivity", + "powerpnt.exe": "Work & Productivity", + # Email — Outlook is email, not productivity + "microsoft outlook": "Email", + "outlook": "Email", + "outlook.exe": "Email", + # Work & Productivity — collaboration/notes + "microsoft teams": "Work & Productivity", + "teams": "Work & Productivity", + "zoom": "Work & Productivity", + "zoom.us": "Work & Productivity", + "notion": "Work & Productivity", + "onenote": "Work & Productivity", + "adobe acrobat": "Work & Productivity", + "acrobat": "Work & Productivity", + "preview": "Work & Productivity", + "pages": "Work & Productivity", + "numbers": "Work & Productivity", + "keynote": "Work & Productivity", + "libreoffice": "Work & Productivity", + # Email — native clients + "mail": "Email", + "thunderbird": "Email", + # Messaging + "slack": "Messaging", + "signal": "Messaging", + "telegram": "Messaging", + "whatsapp": "Messaging", + "messenger": "Messaging", + "discord": "Messaging", + # Music & Audio + "spotify": "Music & Audio", + "music": "Music & Audio", + "apple music": "Music & Audio", + # Video Streaming — media players + "vlc": "Video Streaming", + "quicktime player": "Video Streaming", + "netflix": "Video Streaming", + # Games + "steam": "Games", + "epic games launcher": "Games", + "battle.net": "Games", + "roblox": "Games", + "minecraft": "Games", + "xbox": "Games", + # Work & Productivity — creative/professional software + "photoshop": "Work & Productivity", + "illustrator": "Work & Productivity", + "indesign": "Work & Productivity", + "lightroom": "Work & Productivity", + "premiere pro": "Work & Productivity", + "final cut pro": "Work & Productivity", + "figma": "Work & Productivity", + "blender": "Work & Productivity", + "autocad": "Work & Productivity", + # System utilities — explicitly excluded (not a lookup miss) + "finder": "Excluded", + "explorer": "Excluded", + "explorer.exe": "Excluded", + "system settings": "Excluded", + "settings": "Excluded", + "system preferences": "Excluded", + "terminal": "Excluded", + "cmd.exe": "Excluded", + "powershell.exe": "Excluded", +} + def build_toml_table(entries: list[tuple[str, str]]) -> str: seen: dict[str, str] = {} @@ -654,19 +755,74 @@ def build_toml_table(entries: list[tuple[str, str]]) -> str: return "\n".join(items) -def patch_config(text: str) -> str: - enabled_line = "research_enabled = false" +# The flag must be matched anchored to line start. Since aw-watcher-window #137, +# config.py also documents the rewrite in a comment containing the literal text +# `sed -i 's/^research_enabled = false$/research_enabled = true/'`, and that +# comment appears *before* the real flag. An unanchored replace would patch the +# comment and leave research_enabled = false -- a green build with the Research +# Edition silently disabled. +ENABLED_FLAG_RE = re.compile(r"^research_enabled = false$", re.MULTILINE) + +# Anchor for the post-#137 layout: research knobs moved out of `default_config` +# into a separate template so they are not persisted into every fresh install's +# config file. +RESEARCH_DEFAULTS_ANCHOR = 'research_defaults = """' + +# Proof that the watcher can actually consume an app map (aw-watcher-window #136). +# This is a runtime-capability check, not a layout check, so it survives further +# reshuffling of the config templates. +APP_MAP_RUNTIME_MARKER = 'config.get("research_app_category_map"' + + +def patch_config(text: str) -> tuple[str, bool]: + """Patch config.py text. Returns (patched_text, app_map_injected).""" category_header = "[aw-watcher-window.research_category_map]" - if enabled_line not in text: - raise ValueError(f"'{enabled_line}' not found") - if text.count(category_header) != 1: - raise ValueError(f"expected exactly one '{category_header}' section") + app_category_header = "[aw-watcher-window.research_app_category_map]" + + enabled_matches = len(ENABLED_FLAG_RE.findall(text)) + if enabled_matches != 1: + raise ValueError( + f"expected exactly one line-anchored 'research_enabled = false', found {enabled_matches}" + ) + + # Fail closed: without the runtime lookup the submodule predates PR #136, so + # classify_app() does not exist and non-browser apps would keep their raw + # names. Injecting anyway produces a green build that silently reproduces the + # exact privacy bug this map fixes -- fail loudly instead. + if APP_MAP_RUNTIME_MARKER not in text: + raise ValueError( + "aw-watcher-window does not read research_app_category_map " + "(requires PR #136 in the submodule pin)" + ) entries = build_toml_table(CATEGORY_MAP) - return ( - text.replace(enabled_line, "research_enabled = true", 1) - .replace(category_header, f"{category_header}\n{entries}", 1) - ) + app_entries = build_toml_table(list(APP_CATEGORY_MAP.items())) + + if RESEARCH_DEFAULTS_ANCHOR in text: + # Post-#137: the maps belong inside the `research_defaults` template. + # That template is parsed standalone and merged into the + # [aw-watcher-window] section key-by-key, so its table headers must NOT + # carry the section prefix. + block = ( + "research_enabled = true\n\n" + f"[research_category_map]\n{entries}\n\n" + f"[research_app_category_map]\n{app_entries}" + ) + patched = ENABLED_FLAG_RE.sub(lambda _: block, text, count=1) + else: + # Pre-#137: the section-prefixed headers are already present in + # `default_config`; inject the entries under them. + if text.count(category_header) != 1: + raise ValueError(f"expected exactly one '{category_header}' section") + if text.count(app_category_header) != 1: + raise ValueError(f"expected exactly one '{app_category_header}' section") + patched = ENABLED_FLAG_RE.sub("research_enabled = true", text, count=1) + patched = patched.replace(category_header, f"{category_header}\n{entries}", 1) + patched = patched.replace( + app_category_header, f"{app_category_header}\n{app_entries}", 1 + ) + + return patched, True def main() -> None: @@ -675,7 +831,7 @@ def main() -> None: sys.exit(1) text = CONFIG_FILE.read_text(encoding="utf-8") try: - patched = patch_config(text) + patched, app_map_injected = patch_config(text) except ValueError as error: print(f"Error: {error} in {CONFIG_FILE}", file=sys.stderr) sys.exit(1) @@ -683,6 +839,8 @@ def main() -> None: unique = len({p for p, _ in CATEGORY_MAP}) cats = len({c for _, c in CATEGORY_MAP}) print(f"Injected {unique} unique patterns across {cats} categories into {CONFIG_FILE}") + assert app_map_injected # patch_config() now fails closed rather than skipping + print(f"Injected {len(APP_CATEGORY_MAP)} app-name entries into {CONFIG_FILE}") if __name__ == "__main__": diff --git a/scripts/tests/test_patch_research_edition_config.py b/scripts/tests/test_patch_research_edition_config.py index d76ef0cb5..a8b1c4e99 100644 --- a/scripts/tests/test_patch_research_edition_config.py +++ b/scripts/tests/test_patch_research_edition_config.py @@ -1,4 +1,6 @@ +import ast import importlib.util +import tomllib from pathlib import Path import pytest @@ -11,23 +13,130 @@ SPEC.loader.exec_module(patcher) -def test_patch_config_matches_watcher_config_shape(): - source = '''default_config = """ +# Every real config.py reads the app map at runtime; that lookup is what the +# patcher uses to prove the submodule pin includes aw-watcher-window#136. +RUNTIME_LOOKUPS = """ + +def parse_args(): + parsed_args.research_category_map = dict(config.get("research_category_map", {})) + parsed_args.research_app_category_map = dict(config.get("research_app_category_map", {})) +""" + +# Layout before aw-watcher-window#137: the research tables live in +# default_config, section-prefixed. +PRE_137 = '''default_config = """ [aw-watcher-window] +poll_time = 1.0 research_enabled = false [aw-watcher-window.research_category_map] + +[aw-watcher-window.research_app_category_map] +""".strip() +''' + RUNTIME_LOOKUPS + +# Layout since aw-watcher-window#137: research knobs moved to their own template +# so they are not persisted into every fresh install's config, and a comment +# documents the release-time rewrite -- including the literal flag text. +POST_137 = '''default_config = """ +[aw-watcher-window] +poll_time = 1.0 +""".strip() + +# The Research Edition release build rewrites the flag below with +# sed -i 's/^research_enabled = false$/research_enabled = true/' +# Keep that line at column 0 and byte-identical. +research_defaults = """ +research_enabled = false """.strip() -''' +''' + RUNTIME_LOOKUPS + + +def _string_constant(source: str, name: str) -> str: + """Return the value of a module-level string assignment, unwrapping .strip().""" + for node in ast.parse(source).body: + if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", None) == name: + value = node.value + if isinstance(value, ast.Call): # `""" ... """.strip()` + value = value.func.value + return ast.literal_eval(value) + raise AssertionError(f"{name} not found") + + +def test_pre_137_layout_injects_under_prefixed_headers(): + result, app_map_injected = patcher.patch_config(PRE_137) + + assert app_map_injected is True + ast.parse(result) + + section = tomllib.loads(_string_constant(result, "default_config"))["aw-watcher-window"] + assert section["research_enabled"] is True + assert section["research_category_map"]["svenskaspel.se"] == "Sensitive / Excluded" + assert section["research_app_category_map"]["chatgpt"] == "AI Chatbots & Assistants" + + +def test_post_137_layout_injects_into_research_defaults(): + """The tables must land in research_defaults, with unprefixed headers. + + research_defaults is parsed standalone and merged into the + [aw-watcher-window] section key by key, so a section-prefixed header there + would produce a nested `aw-watcher-window` key that nothing reads. + """ + result, app_map_injected = patcher.patch_config(POST_137) + + assert app_map_injected is True + ast.parse(result) + + defaults = tomllib.loads(_string_constant(result, "research_defaults")) + assert "aw-watcher-window" not in defaults + assert defaults["research_enabled"] is True + assert defaults["research_category_map"]["svenskaspel.se"] == "Sensitive / Excluded" + assert defaults["research_app_category_map"]["chatgpt"] == "AI Chatbots & Assistants" + + +def test_flag_rewrite_is_line_anchored_and_spares_the_sed_comment(): + """The #137 comment contains the literal flag text, and precedes the real flag. + + An unanchored replace patches the comment and leaves research_enabled = false + -- a green build shipping a Research Edition with research silently disabled. + """ + result, _ = patcher.patch_config(POST_137) + + assert "s/^research_enabled = false$/research_enabled = true/" in result + assert tomllib.loads(_string_constant(result, "research_defaults"))["research_enabled"] is True + + +def test_fails_closed_on_pre_136_submodule_pin(): + """A pin without the app-map lookup must abort, not ship raw app names. + + Regression guard for the build that green-lit an artifact reproducing the + exact 'still only uncategorized' symptom the app map exists to fix. + """ + without_lookup = POST_137.replace( + 'config.get("research_app_category_map"', 'config.get("something_else"' + ) + + with pytest.raises(ValueError, match="research_app_category_map"): + patcher.patch_config(without_lookup) + + +def test_fails_closed_when_flag_is_missing(): + with pytest.raises(ValueError, match="research_enabled = false"): + patcher.patch_config(f'default_config = """\n[aw-watcher-window]\n"""{RUNTIME_LOOKUPS}') + + +def test_fails_closed_on_ambiguous_flag(): + """Two line-anchored flags mean an unknown layout -- refuse rather than guess.""" + ambiguous = POST_137.replace( + "research_enabled = false\n", "research_enabled = false\nresearch_enabled = false\n", 1 + ) - result = patcher.patch_config(source) + with pytest.raises(ValueError, match="found 2"): + patcher.patch_config(ambiguous) - assert "research_enabled = true" in result - assert "research_enabled = false" not in result - assert '[aw-watcher-window.research_category_map]\n"svenskaspel.se" = "Sensitive / Excluded"' in result - assert '"kagi" = "Search & Navigation"\n""".strip()' in result +def test_pre_137_layout_still_fails_closed_without_app_section(): + missing_app_table = PRE_137.replace("\n[aw-watcher-window.research_app_category_map]\n", "\n") -def test_patch_config_fails_closed_without_category_section(): - with pytest.raises(ValueError, match="research_category_map"): - patcher.patch_config("research_enabled = false\n") + with pytest.raises(ValueError, match="research_app_category_map"): + patcher.patch_config(missing_app_table)