diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 019979ff8..044f7e8db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -432,11 +432,9 @@ jobs: if: env.AW_RESEARCH_EDITION == 'true' run: python3 scripts/patch_research_edition_export.py - # The watcher rewrites `app` to a study category before storing, but - # aw-webui categorises client-side with its own defaults and never sees - # the watcher's map -- so without this the Categories panel reads - # "Uncategorized" while Top Applications shows the right categories. - # Derived from the same map as the step above, so the two cannot drift. + # The watcher keeps approved app names and replaces browser titles with a + # study category. This preset maps both representations into the study + # taxonomy so Top Applications and Top Categories agree. - name: Emit research edition category preset for the web UI if: env.AW_RESEARCH_EDITION == 'true' shell: bash @@ -709,6 +707,10 @@ jobs: # setup steps: enable start-at-login on first launch (aw-qt#131). python3 scripts/patch_research_edition_awqt.py aw-qt/aw_qt/config.py + - name: Patch research edition export hostname sanitizer + if: env.AW_RESEARCH_EDITION == 'true' + run: python3 scripts/patch_research_edition_export.py + - name: Emit research edition category preset for the web UI if: env.AW_RESEARCH_EDITION == 'true' run: | @@ -922,11 +924,9 @@ jobs: if: env.AW_RESEARCH_EDITION == 'true' run: python3 scripts/patch_research_edition_export.py - # The watcher rewrites `app` to a study category before storing, but - # aw-webui categorises client-side with its own defaults and never sees - # the watcher's map -- so without this the Categories panel reads - # "Uncategorized" while Top Applications shows the right categories. - # Derived from the same map as the step above, so the two cannot drift. + # The watcher keeps approved app names and replaces browser titles with a + # study category. This preset maps both representations into the study + # taxonomy so Top Applications and Top Categories agree. - name: Emit research edition category preset for the web UI if: env.AW_RESEARCH_EDITION == 'true' shell: bash diff --git a/scripts/emit_research_category_preset.py b/scripts/emit_research_category_preset.py index e22f61930..1c85237f3 100644 --- a/scripts/emit_research_category_preset.py +++ b/scripts/emit_research_category_preset.py @@ -1,23 +1,24 @@ #!/usr/bin/env python3 """Emit the Research Edition category preset consumed by aw-webui at build time. -The watcher rewrites `app` to a study category before the event is stored -(see patch_research_edition_config.py). aw-webui, however, categorises -client-side with its own default regexes and never sees the watcher's map, so -without this preset the Categories panel reads "Uncategorized" while Top -Applications shows the correct categories -- the data is right and the UI -disagrees with it. That is the exact symptom the Lund study reported on -v0.14.0b3-research. +The approved study contract keeps application names while the watcher replaces +browser titles with study categories and removes every URL. aw-webui categorises +client-side, so this preset matches both stored browser category labels and the +known raw application-name aliases. Top Applications can therefore show Word, +Spotify, and Teams while Top Categories still uses the study taxonomy. aw-webui (ActivityWatch/aw-webui#936) reads a preset category set from the `AW_PRESET_CATEGORY_SETS` env var at build time. This script derives that -preset from the same single source of truth as the watcher map, so the two can +preset from the same taxonomy source as the watcher build patch, so the two can never drift: python3 scripts/emit_research_category_preset.py > preset.json -Rules match on the category name anchored to the whole value, because by the -time aw-webui sees an event, `app` *is* the category name. +Rules are exact, case-insensitive matches. aw-webui applies every category rule +to `app` and `title`, and the oldest web UI pinned by the release carriers drops +unknown per-rule metadata, so the preset cannot rely on field or priority keys. +Explicitly excluded app aliases map to `Excluded`; unknown applications remain +`Uncategorized` instead of overlapping every specific rule with a catch-all. """ import importlib.util @@ -64,6 +65,12 @@ def escape_portable(value: str) -> str: ) +def exact_alternation(values: set[str]) -> str: + """Build a stable whole-value alternation portable across Python and JS.""" + escaped = [escape_portable(value) for value in sorted(values)] + return f"^(?:{'|'.join(escaped)})$" + + def build_preset() -> dict: source = _load_category_source() @@ -75,14 +82,21 @@ def build_preset() -> dict: return { "id": PRESET_ID, "name": PRESET_NAME, - # Sorted so the same map always produces a byte-identical preset. + # Sorted so the same taxonomy always produces a byte-identical preset. "categories": [ { "name": [category], "rule": { "type": "regex", - "regex": f"^{escape_portable(category)}$", - "ignore_case": False, + "regex": exact_alternation( + {category} + | { + app + for app, app_category in source.APP_CATEGORY_MAP.items() + if app_category == category + } + ), + "ignore_case": True, }, } for category in sorted(categories) diff --git a/scripts/patch_research_edition_config.py b/scripts/patch_research_edition_config.py index 34d4974e8..0a59cd185 100644 --- a/scripts/patch_research_edition_config.py +++ b/scripts/patch_research_edition_config.py @@ -1,11 +1,9 @@ #!/usr/bin/env python3 -"""Patch aw-watcher-window/config.py with Matthias's research edition category maps. +"""Patch aw-watcher-window/config.py with Research Edition browser categories. Run as part of the CI build for research edition: python3 scripts/patch_research_edition_config.py -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. @@ -13,19 +11,19 @@ (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. +APP_CATEGORY_MAP is retained as the shared taxonomy for the web UI preset, but +is deliberately not injected into the watcher. The approved study contract keeps +application names (including browser identity) while discarding raw titles and +URLs. An empty watcher app map selects exactly that behavior. """ + import pathlib import re import sys CONFIG_FILE = pathlib.Path( - sys.argv[1] if len(sys.argv) > 1 + sys.argv[1] + if len(sys.argv) > 1 else "aw-watcher-window/aw_watcher_window/config.py" ) @@ -651,10 +649,10 @@ ("kagi", "Search & Navigation"), ] -# App-name → study category mapping for non-browser applications. +# App-name → study category aliases used by the web UI preset. # 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. +# These aliases are not injected into the watcher: Research Edition storage keeps +# the approved raw app name and removes the non-browser title and every URL. APP_CATEGORY_MAP: dict[str, str] = { # AI chatbots & assistants "chatgpt": "AI Chatbots & Assistants", @@ -768,16 +766,10 @@ def build_toml_table(entries: list[tuple[str, str]]) -> str: # 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).""" +def patch_config(text: str) -> str: + """Enable browser categorization while leaving the watcher app map empty.""" category_header = "[aw-watcher-window.research_category_map]" - app_category_header = "[aw-watcher-window.research_app_category_map]" enabled_matches = len(ENABLED_FLAG_RE.findall(text)) if enabled_matches != 1: @@ -785,44 +777,29 @@ def patch_config(text: str) -> tuple[str, bool]: 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) - 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. + # Post-#137: the browser map belongs inside `research_defaults`. # 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}" - ) + # Include an empty [research_app_category_map] so that an upgrade from + # an earlier Research Edition (which may have had this map populated) + # explicitly clears it via the key-by-key merge. Without this, a prior + # non-empty app map survives the upgrade and continues replacing app + # names with categories, defeating the primary behavior change. + block = f"research_enabled = true\n\n[research_category_map]\n{entries}\n\n[research_app_category_map]" 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. + # Pre-#137: the section-prefixed browser header is already present in + # `default_config`; leave the app-map section empty. 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 + return patched def main() -> None: @@ -831,16 +808,17 @@ def main() -> None: sys.exit(1) text = CONFIG_FILE.read_text(encoding="utf-8") try: - patched, app_map_injected = patch_config(text) + patched = patch_config(text) except ValueError as error: print(f"Error: {error} in {CONFIG_FILE}", file=sys.stderr) sys.exit(1) CONFIG_FILE.write_text(patched, encoding="utf-8") 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}") + print( + f"Injected {unique} unique patterns across {cats} categories into {CONFIG_FILE}" + ) + print("Preserving application names; watcher app-category map left empty") if __name__ == "__main__": diff --git a/scripts/research_edition/export_sanitize.rs b/scripts/research_edition/export_sanitize.rs index b16e5f249..803025a4d 100644 --- a/scripts/research_edition/export_sanitize.rs +++ b/scripts/research_edition/export_sanitize.rs @@ -4,8 +4,8 @@ //! when `AW_RESEARCH_EDITION=true`. Standard builds never see this module. //! //! Two jobs: -//! 1. Fail closed if currentwindow events still carry raw titles/URLs/app names -//! (an existing ActivityWatch database is not a study-safe profile). +//! 1. Fail closed if currentwindow events still carry raw titles or any event +//! carries a URL. Application names are approved study variables. //! 2. Rewrite each exported bucket's map key, embedded `id`, and `hostname` //! so the real machine name never leaves the device. Colliding sanitized //! IDs fail the export rather than silently merging two machines. @@ -41,37 +41,6 @@ const STUDY_CATEGORIES: &[&str] = &[ "excluded", ]; -/// Browser app names the Research filter leaves in `app` while classifying the -/// title. Copied from `aw-watcher-window/aw_watcher_window/research_filter.py`. -const BROWSER_APPS: &[&str] = &[ - "chrome", - "google chrome", - "google chrome canary", - "google-chrome", - "google-chrome-beta", - "google-chrome-unstable", - "chromium", - "chromium-browser", - "brave browser", - "brave", - "brave-browser", - "firefox", - "firefox developer edition", - "firefox-esr", - "safari", - "edge", - "microsoft edge", - "microsoft-edge", - "microsoft-edge-beta", - "microsoft-edge-dev", - "opera", - "chrome.exe", - "brave.exe", - "firefox.exe", - "msedge.exe", - "opera.exe", -]; - pub fn sanitize_buckets_export(export: BucketsExport) -> Result { let mut checked = HashMap::new(); for (key, mut bucket) in export.buckets { @@ -101,7 +70,10 @@ fn reject_unfiltered(bucket: &mut Bucket) -> Result<(), String> { Ok(()) } -fn unfiltered_reason(bucket_type: &str, data: &serde_json::Map) -> Option<&'static str> { +fn unfiltered_reason( + bucket_type: &str, + data: &serde_json::Map, +) -> Option<&'static str> { if data.contains_key("url") { return Some("url field"); } @@ -113,11 +85,6 @@ fn unfiltered_reason(bucket_type: &str, data: &serde_json::Map) - return Some("non-category window title"); } } - if let Some(app) = data.get("app").and_then(Value::as_str) { - if !is_allowed_window_app(app) { - return Some("non-category window app"); - } - } None } @@ -128,10 +95,6 @@ fn is_study_category(value: &str) -> bool { .any(|category| category.eq_ignore_ascii_case(trimmed)) } -fn is_allowed_window_app(app: &str) -> bool { - is_study_category(app) || BROWSER_APPS.iter().any(|name| name.eq_ignore_ascii_case(app.trim())) -} - fn rewrite_identities(buckets: HashMap) -> Result { let mut out: HashMap = HashMap::new(); for (key, mut bucket) in buckets { @@ -199,7 +162,13 @@ mod tests { } } - fn bucket(id: &str, hostname: &str, bucket_type: &str, client: &str, events: Vec) -> Bucket { + fn bucket( + id: &str, + hostname: &str, + bucket_type: &str, + client: &str, + events: Vec, + ) -> Bucket { Bucket { bid: None, id: id.to_string(), @@ -216,10 +185,7 @@ mod tests { fn export_of(buckets: Vec) -> BucketsExport { BucketsExport { - buckets: buckets - .into_iter() - .map(|b| (b.id.clone(), b)) - .collect(), + buckets: buckets.into_iter().map(|b| (b.id.clone(), b)).collect(), } } @@ -265,7 +231,10 @@ mod tests { ); let dump = serde_json::to_string(&sanitized).unwrap(); - assert!(!dump.contains(host), "real hostname must not appear in export JSON"); + assert!( + !dump.contains(host), + "real hostname must not appear in export JSON" + ); assert_eq!(dump.matches(SANITIZED_HOSTNAME).count(), 6); // key + id + hostname, twice } @@ -324,7 +293,9 @@ mod tests { "host", "web.tab.current", "aw-watcher-web", - vec![event(json!({"url": "https://mail.example/inbox", "title": "Inbox"}))], + vec![event( + json!({"url": "https://mail.example/inbox", "title": "Inbox"}), + )], )]); let err = match sanitize_buckets_export(original) { @@ -335,6 +306,30 @@ mod tests { assert!(!err.contains("mail.example")); } + #[test] + fn retained_non_browser_app_name_is_allowed() { + let original = export_of(vec![bucket( + "aw-watcher-window_host", + "host", + "currentwindow", + "aw-watcher-window", + vec![event(json!({"app": "Microsoft Word"}))], + )]); + + let sanitized = sanitize_buckets_export(original).unwrap(); + let bucket = sanitized + .buckets + .get(&format!("aw-watcher-window_{SANITIZED_HOSTNAME}")) + .unwrap(); + assert_eq!( + bucket.events.as_ref().unwrap().clone().take_inner()[0].data, + json!({"app": "Microsoft Word"}) + .as_object() + .unwrap() + .clone() + ); + } + #[test] fn browser_event_with_classified_title_is_allowed() { let original = export_of(vec![bucket( @@ -342,7 +337,9 @@ mod tests { "host", "currentwindow", "aw-watcher-window", - vec![event(json!({"app": "Firefox", "title": "Work & Productivity"}))], + vec![event( + json!({"app": "Firefox", "title": "Work & Productivity"}), + )], )]); let sanitized = sanitize_buckets_export(original).unwrap(); @@ -362,7 +359,10 @@ mod tests { #[test] fn sanitize_id_replaces_suffix_and_embedded_hostname() { assert_eq!( - sanitize_id("aw-watcher-window_Participant-Alice-MacBook", "Participant-Alice-MacBook"), + sanitize_id( + "aw-watcher-window_Participant-Alice-MacBook", + "Participant-Alice-MacBook" + ), format!("aw-watcher-window_{SANITIZED_HOSTNAME}") ); assert_eq!( diff --git a/scripts/tests/test_emit_research_category_preset.py b/scripts/tests/test_emit_research_category_preset.py index 4da37d090..0066f2a3e 100644 --- a/scripts/tests/test_emit_research_category_preset.py +++ b/scripts/tests/test_emit_research_category_preset.py @@ -19,22 +19,80 @@ def _load(name: str): def test_preset_covers_every_category_in_the_watcher_map(): """Preset and watcher map must share one source, or the UI drifts from the data.""" - expected = {c for _, c in patcher.CATEGORY_MAP} | set(patcher.APP_CATEGORY_MAP.values()) + expected = {c for _, c in patcher.CATEGORY_MAP} | set( + patcher.APP_CATEGORY_MAP.values() + ) names = {c["name"][0] for c in emitter.build_preset()["categories"]} assert names == expected -def test_rules_match_their_own_category_and_nothing_else(): - """By the time aw-webui sees the event, `app` IS the category name.""" +def _classify_event(data: dict[str, str]) -> str | None: + """Mirror aw-webui's parsed-rule contract and equal-depth matching.""" + matches: list[str] = [] for category in emitter.build_preset()["categories"]: - name = category["name"][0] - pattern = category["rule"]["regex"] + rule = category["rule"] + flags = re.IGNORECASE if rule["ignore_case"] else 0 + if any( + key in data and re.search(rule["regex"], data[key], flags) + for key in ("app", "title") + ): + matches.append(category["name"][0]) + assert len(matches) <= 1, ( + f"aw-webui cannot prioritize equal-depth matches: {matches}" + ) + return matches[0] if matches else None + + +def test_rules_match_browser_categories_and_raw_app_aliases(): + """Browser titles and retained app names resolve to the same taxonomy.""" + assert _classify_event({"app": "Safari", "title": "Work & Productivity"}) == ( + "Work & Productivity" + ) + assert _classify_event({"app": "Microsoft Word"}) == "Work & Productivity" + assert _classify_event({"app": "SPOTIFY"}) == "Music & Audio" + assert _classify_event({"app": "Terminal"}) == "Excluded" + assert _classify_event({"app": "Some Unmapped Program"}) is None + + +def test_rules_are_exact_and_scoped_to_approved_fields(): + categories = { + category["name"][0]: category + for category in emitter.build_preset()["categories"] + } + work_rule = categories["Work & Productivity"]["rule"] + + assert re.search(work_rule["regex"], "Microsoft Word", re.IGNORECASE) + assert not re.search(work_rule["regex"], "Microsoft Word extra", re.IGNORECASE) + assert _classify_event({"hostname": "Microsoft Word"}) is None + + +def test_rules_use_only_fields_preserved_by_the_aw_webui_preset_parser(): + """The oldest pinned carrier strips unknown keys; behavior must survive that.""" + for category in emitter.build_preset()["categories"]: + assert set(category["rule"]) <= {"type", "regex", "ignore_case"} + + +def test_excluded_rule_only_matches_explicitly_excluded_apps(): + categories = { + category["name"][0]: category + for category in emitter.build_preset()["categories"] + } + + excluded = categories["Excluded"]["rule"]["regex"] + assert re.search(excluded, "Terminal", re.IGNORECASE) + assert not re.search(excluded, "Outlook", re.IGNORECASE) + assert not re.search(excluded, "Some Unmapped Program", re.IGNORECASE) - assert re.search(pattern, name), f"{pattern!r} does not match {name!r}" - assert not re.search(pattern, "Some Unrelated App") - assert not re.search(pattern, f"{name} extra") + +def test_every_known_value_matches_exactly_one_rule_after_parser_projection(): + """Catch overlaps the production classifier cannot resolve at equal depth.""" + values = {category["name"][0] for category in emitter.build_preset()["categories"]} + values |= set(patcher.APP_CATEGORY_MAP) + + for value in values: + assert _classify_event({"app": value}) is not None def test_escaping_is_portable_to_javascript_unicode_mode(): @@ -64,6 +122,15 @@ def test_escape_portable_still_escapes_real_metacharacters(): assert not re.fullmatch(emitter.escape_portable("a.b"), "axb") +def test_exact_alternation_is_stable_and_whole_value(): + pattern = emitter.exact_alternation({"zoom.us", "Zoom"}) + + assert pattern == r"^(?:Zoom|zoom\.us)$" + assert re.fullmatch(pattern, "Zoom") + assert re.fullmatch(pattern, "zoom.us") + assert not re.fullmatch(pattern, "zoom.us meeting") + + def test_output_is_stable_across_runs(): """CI reruns must not produce a different preset from the same map.""" assert emitter.build_preset() == emitter.build_preset() diff --git a/scripts/tests/test_patch_research_edition_config.py b/scripts/tests/test_patch_research_edition_config.py index a8b1c4e99..2be20d15d 100644 --- a/scripts/tests/test_patch_research_edition_config.py +++ b/scripts/tests/test_patch_research_edition_config.py @@ -13,8 +13,8 @@ SPEC.loader.exec_module(patcher) -# 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. +# Current config.py reads both maps. The Research Edition patch deliberately +# leaves the app map empty so approved application names survive capture. RUNTIME_LOOKUPS = """ def parse_args(): @@ -24,7 +24,8 @@ def parse_args(): # Layout before aw-watcher-window#137: the research tables live in # default_config, section-prefixed. -PRE_137 = '''default_config = """ +PRE_137 = ( + '''default_config = """ [aw-watcher-window] poll_time = 1.0 research_enabled = false @@ -33,12 +34,15 @@ def parse_args(): [aw-watcher-window.research_app_category_map] """.strip() -''' + RUNTIME_LOOKUPS +''' + + 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 = """ +POST_137 = ( + '''default_config = """ [aw-watcher-window] poll_time = 1.0 """.strip() @@ -49,13 +53,18 @@ def parse_args(): research_defaults = """ research_enabled = false """.strip() -''' + RUNTIME_LOOKUPS +''' + + 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: + 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 @@ -63,35 +72,94 @@ def _string_constant(source: str, name: str) -> str: raise AssertionError(f"{name} not found") -def test_pre_137_layout_injects_under_prefixed_headers(): - result, app_map_injected = patcher.patch_config(PRE_137) +def test_pre_137_layout_injects_browser_map_and_preserves_app_names(): + result = 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"] + 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" + assert section["research_app_category_map"] == {} -def test_post_137_layout_injects_into_research_defaults(): +def test_post_137_layout_injects_only_browser_map_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) + result = 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" + # An empty table is emitted so upgrades from an earlier Research Edition + # (which may have had this map populated) explicitly clear it. + assert defaults["research_app_category_map"] == {} + + +def test_post_137_emits_explicit_empty_app_map_to_clear_legacy_entries(): + """The patched research_defaults must include an explicit empty app-category table. + + An upgrade from an earlier Research Edition may have a populated + research_app_category_map in the user's saved config. Because research_defaults + is merged into [aw-watcher-window] key-by-key, omitting the table would leave + legacy entries intact, so application names would continue to be replaced with + categories, defeating the primary behavior change. The explicit empty section + header ensures the watcher's merge logic resets the key. + """ + result = patcher.patch_config(POST_137) + + defaults_str = _string_constant(result, "research_defaults") + # The literal section header must appear so older installs see an explicit reset. + assert "[research_app_category_map]" in defaults_str + assert tomllib.loads(defaults_str)["research_app_category_map"] == {} + + +def test_patched_config_matches_the_pinned_watcher_storage_contract(): + """Approved app names survive, while browser content and every URL do not.""" + root = Path(__file__).resolve().parents[2] + config_path = root / "aw-watcher-window/aw_watcher_window/config.py" + filter_path = root / "aw-watcher-window/aw_watcher_window/research_filter.py" + if not config_path.is_file() or not filter_path.is_file(): + pytest.skip("aw-watcher-window not checked out") + + patched = patcher.patch_config(config_path.read_text(encoding="utf-8")) + defaults = tomllib.loads(_string_constant(patched, "research_defaults")) + + spec = importlib.util.spec_from_file_location("research_filter", filter_path) + assert spec and spec.loader + research_filter = importlib.util.module_from_spec(spec) + spec.loader.exec_module(research_filter) + + word = research_filter.transform( + { + "app": "Microsoft Word", + "title": "confidential-draft.docx", + "url": "file:///private/confidential-draft.docx", + }, + defaults["research_category_map"], + defaults.get("research_app_category_map"), + ) + safari = research_filter.transform( + { + "app": "Safari", + "title": "Confidential draft - Google Docs", + "url": "https://docs.google.com/document/private-id", + }, + defaults["research_category_map"], + defaults.get("research_app_category_map"), + ) + + assert word == {"app": "Microsoft Word"} + assert safari == {"app": "Safari", "title": "Work & Productivity"} def test_flag_rewrite_is_line_anchored_and_spares_the_sed_comment(): @@ -100,43 +168,56 @@ def test_flag_rewrite_is_line_anchored_and_spares_the_sed_comment(): 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) + 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 - + 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. - """ +def test_app_map_runtime_support_is_not_required_for_name_preserving_build(): 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) + result = patcher.patch_config(without_lookup) + + defaults = tomllib.loads(_string_constant(result, "research_defaults")) + # An empty app-map table is always emitted to clear legacy populated maps; + # the watcher ignores it if it does not read research_app_category_map. + assert defaults["research_app_category_map"] == {} 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}') + 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 + "research_enabled = false\n", + "research_enabled = false\nresearch_enabled = false\n", + 1, ) with pytest.raises(ValueError, match="found 2"): patcher.patch_config(ambiguous) -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_pre_137_layout_does_not_require_app_section(): + missing_app_table = PRE_137.replace( + "\n[aw-watcher-window.research_app_category_map]\n", "\n" + ) + + result = patcher.patch_config(missing_app_table) - with pytest.raises(ValueError, match="research_app_category_map"): - patcher.patch_config(missing_app_table) + section = tomllib.loads(_string_constant(result, "default_config"))[ + "aw-watcher-window" + ] + assert section["research_enabled"] is True + assert "research_app_category_map" not in section diff --git a/scripts/tests/test_patch_research_edition_export.py b/scripts/tests/test_patch_research_edition_export.py index 00d880c46..4d82002de 100644 --- a/scripts/tests/test_patch_research_edition_export.py +++ b/scripts/tests/test_patch_research_edition_export.py @@ -62,7 +62,9 @@ def test_patch_inserts_module_and_both_call_sites(tmp_path: Path): bucket = (root / "aw-server-rust/aw-server/src/endpoints/bucket.rs").read_text( encoding="utf-8" ) - mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text(encoding="utf-8") + mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text( + encoding="utf-8" + ) copied = root / "aw-server-rust/aw-server/src/endpoints/export_sanitize.rs" assert copied.is_file() @@ -86,7 +88,9 @@ def test_patch_is_idempotent(tmp_path: Path): encoding="utf-8" ) assert first == second - mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text(encoding="utf-8") + mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text( + encoding="utf-8" + ) assert mod.count("mod export_sanitize;") == 1 @@ -104,8 +108,14 @@ def test_live_tree_is_patchable_or_already_patched(): pytest.skip("aw-server-rust not checked out") export_text = export.read_text(encoding="utf-8") bucket_text = bucket.read_text(encoding="utf-8") - assert patcher.MARKER in export_text or export_text.count(patcher.EXPORT_INSERT_NEEDLE) == 1 - assert patcher.MARKER in bucket_text or bucket_text.count(patcher.BUCKET_INSERT_NEEDLE) == 1 + assert ( + patcher.MARKER in export_text + or export_text.count(patcher.EXPORT_INSERT_NEEDLE) == 1 + ) + assert ( + patcher.MARKER in bucket_text + or bucket_text.count(patcher.BUCKET_INSERT_NEEDLE) == 1 + ) def test_sanitizer_allowlist_covers_config_categories(): @@ -118,3 +128,12 @@ def test_sanitizer_allowlist_covers_config_categories(): expected.update({"Excluded", "excluded"}) missing = [category for category in expected if f'"{category}"' not in rust] assert missing == [] + + +def test_every_research_build_patches_the_export_sanitizer(): + workflow = ( + Path(__file__).resolve().parents[2] / ".github" / "workflows" / "release.yml" + ).read_text(encoding="utf-8") + + assert workflow.count("python3 scripts/patch_research_edition_config.py") == 3 + assert workflow.count("python3 scripts/patch_research_edition_export.py") == 3