| 1 | from pathlib import Path |
| 2 | import shutil |
| 3 | import subprocess |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | |
| 8 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 9 | |
| 10 | |
| 11 | def read(*parts: str) -> str: |
| 12 | return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8") |
| 13 | |
| 14 | |
| 15 | def extract_js_function(source: str, name: str) -> str: |
| 16 | start = source.find(f"function {name}(") |
| 17 | if start < 0: |
| 18 | raise AssertionError(f"Could not find JavaScript function: {name}") |
| 19 | brace = source.find("{", start) |
| 20 | if brace < 0: |
| 21 | raise AssertionError(f"Could not find opening brace for JavaScript function: {name}") |
| 22 | depth = 0 |
| 23 | quote = "" |
| 24 | escape = False |
| 25 | line_comment = False |
| 26 | block_comment = False |
| 27 | regex_literal = False |
| 28 | regex_char_class = False |
| 29 | index = brace |
| 30 | |
| 31 | while index < len(source): |
| 32 | char = source[index] |
| 33 | next_char = source[index + 1] if index + 1 < len(source) else "" |
| 34 | |
| 35 | if line_comment: |
| 36 | line_comment = char != "\n" |
| 37 | elif block_comment: |
| 38 | if char == "*" and next_char == "/": |
| 39 | block_comment = False |
| 40 | index += 1 |
| 41 | elif regex_literal: |
| 42 | if escape: |
| 43 | escape = False |
| 44 | elif char == "\\": |
| 45 | escape = True |
| 46 | elif char == "[": |
| 47 | regex_char_class = True |
| 48 | elif char == "]": |
| 49 | regex_char_class = False |
| 50 | elif char == "/" and not regex_char_class: |
| 51 | regex_literal = False |
| 52 | elif quote: |
| 53 | if escape: |
| 54 | escape = False |
| 55 | elif char == "\\": |
| 56 | escape = True |
| 57 | elif char == quote: |
| 58 | quote = "" |
| 59 | elif char == "/" and next_char == "/": |
| 60 | line_comment = True |
| 61 | index += 1 |
| 62 | elif char == "/" and next_char == "*": |
| 63 | block_comment = True |
| 64 | index += 1 |
| 65 | elif char == "/" and previous_non_space(source, index) in {"=", "(", ",", ":"}: |
| 66 | regex_literal = True |
| 67 | regex_char_class = False |
| 68 | elif char in {"'", '"', "`"}: |
| 69 | quote = char |
| 70 | elif char == "{": |
| 71 | depth += 1 |
| 72 | elif char == "}": |
| 73 | depth -= 1 |
| 74 | if depth == 0: |
| 75 | return source[start:index + 1] |
| 76 | |
| 77 | index += 1 |
| 78 | |
| 79 | raise AssertionError(f"Could not find complete JavaScript function: {name}") |
| 80 | |
| 81 | |
| 82 | def previous_non_space(source: str, index: int) -> str: |
| 83 | cursor = index - 1 |
| 84 | while cursor >= 0 and source[cursor].isspace(): |
| 85 | cursor -= 1 |
| 86 | return source[cursor] if cursor >= 0 else "" |
| 87 | |
| 88 | |
| 89 | def test_notification_store_supports_persistent_grouped_toasts(): |
| 90 | store = read("webui", "components", "notifications", "notification-store.js") |
| 91 | toast_stack = read("webui", "components", "notifications", "notification-toast-stack.html") |
| 92 | api = read("api", "notification_create.py") |
| 93 | plugins = read("helpers", "plugins.py") |
| 94 | update_check = read("extensions", "python", "user_message_ui", "_10_update_check.py") |
| 95 | |
| 96 | assert "isPersistentToast(toast)" in store |
| 97 | assert "return this.getToastDisplayTime(toast) <= 0;" in store |
| 98 | assert store.count("if (this.isPersistentToast(toast)) return;") >= 2 |
| 99 | assert "this.restartToastTimer(toast.toastId);" in store |
| 100 | assert "this.removeFromToastStack(existingToast.toastId);" in store |
| 101 | assert "if display_time < 0:" in api |
| 102 | assert "if display_time <= 0:" not in api |
| 103 | assert 'id="plugins_frontend_reload",' in plugins |
| 104 | assert "$store.notificationStore.dismissToastAndReload(toast.toastId)" in plugins |
| 105 | assert "onclick=\"window.location.reload()\"" not in plugins |
| 106 | assert 'id=notif.get("id", "update_check_available"),' in update_check |
| 107 | assert "display_time=0," in plugins |
| 108 | assert "display_time=0," in update_check |
| 109 | assert 'class="toast-action-row"' in plugins |
| 110 | assert 'class="toast-action-row"' in update_check |
| 111 | assert 'class="button confirm"' in update_check |
| 112 | assert "$store.notificationStore.dismissToast(toast.toastId)" in update_check |
| 113 | assert ".toast-action-row" in toast_stack |
| 114 | assert "margin-top: var(--spacing-sm);" in toast_stack |
| 115 | assert "async dismissToastAndReload(toastId)" in store |
| 116 | assert 'await API.callJsonApi("notifications_mark_read"' in store |
| 117 | assert "if (response?.success) window.location.reload();" in store |
| 118 | |
| 119 | |
| 120 | def test_backup_zip_downloads_emit_grouped_preparing_and_downloading_toasts(): |
| 121 | store = read("webui", "components", "settings", "backup", "backup-store.js") |
| 122 | |
| 123 | assert 'window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);' in store |
| 124 | assert 'window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);' in store |
| 125 | assert 'window.toastFrontendError?.(message || "Download failed", "Download Error", 8, group, undefined, true);' in store |
| 126 | assert 'this.createDownloadToastGroup("backup-create")' in store |
| 127 | assert 'this.createDownloadToastGroup("backup-download")' in store |
| 128 | |
| 129 | create_start = store.index("async createBackup()") |
| 130 | create_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", create_start) |
| 131 | create_fetch = store.index("const response = await fetchApi('/backup_create'", create_start) |
| 132 | assert create_prepare < create_fetch |
| 133 | |
| 134 | download_start = store.index("async downloadBackup") |
| 135 | download_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", download_start) |
| 136 | download_fetch = store.index("const response = await fetchApi('/backup_download'", download_start) |
| 137 | assert download_prepare < download_fetch |
| 138 | |
| 139 | |
| 140 | def test_file_browser_zip_downloads_emit_grouped_preparing_and_downloading_toasts(): |
| 141 | store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") |
| 142 | |
| 143 | assert 'window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);' in store |
| 144 | assert 'window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);' in store |
| 145 | assert 'this.createDownloadToastGroup("file-browser-bulk-download")' in store |
| 146 | assert 'this.createDownloadToastGroup("file-browser-directory-download")' in store |
| 147 | assert "if (file.is_dir) {" in store |
| 148 | assert "return this.downloadDirectory(file);" in store |
| 149 | assert "link.download = file.name;" in store |
| 150 | |
| 151 | bulk_start = store.index("async bulkDownloadFiles()") |
| 152 | bulk_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", bulk_start) |
| 153 | bulk_fetch = store.index('const resp = await fetchApi("/download_work_dir_files"', bulk_start) |
| 154 | assert bulk_prepare < bulk_fetch |
| 155 | |
| 156 | directory_start = store.index("async downloadDirectory(file)") |
| 157 | directory_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", directory_start) |
| 158 | directory_fetch = store.index("const resp = await fetchApi(`/download_work_dir_file", directory_start) |
| 159 | assert directory_prepare < directory_fetch |
| 160 | |
| 161 | |
| 162 | def test_message_path_links_keep_spaces_in_file_names(): |
| 163 | # This regression executes convertPathsToLinks with Node.js to catch browser-path parsing drift. |
| 164 | if not shutil.which("node"): |
| 165 | pytest.skip("Node.js is required to execute the message path-linking regression.") |
| 166 | |
| 167 | messages = read("webui", "js", "messages.js") |
| 168 | function_source = extract_js_function(messages, "convertPathsToLinks") |
| 169 | |
| 170 | script = f""" |
| 171 | {function_source} |
| 172 | |
| 173 | function assertIncludes(value, expected) {{ |
| 174 | if (!value.includes(expected)) {{ |
| 175 | throw new Error(`Expected ${{JSON.stringify(value)}} to include ${{JSON.stringify(expected)}}`); |
| 176 | }} |
| 177 | }} |
| 178 | |
| 179 | function assertNotIncludes(value, expected) {{ |
| 180 | if (value.includes(expected)) {{ |
| 181 | throw new Error(`Expected ${{JSON.stringify(value)}} not to include ${{JSON.stringify(expected)}}`); |
| 182 | }} |
| 183 | }} |
| 184 | |
| 185 | const spaced = convertPathsToLinks("Location: /a0/usr/workdir/New Document.md"); |
| 186 | assertIncludes(spaced, 'data-path="/a0/usr/workdir/New Document.md"'); |
| 187 | assertIncludes(spaced, '>New Document.md</a>'); |
| 188 | assertNotIncludes(spaced, '>New</a> Document.md'); |
| 189 | |
| 190 | const sentence = convertPathsToLinks("Saved at /a0/usr/workdir/New Document.md and ready."); |
| 191 | assertIncludes(sentence, 'data-path="/a0/usr/workdir/New Document.md"'); |
| 192 | assertNotIncludes(sentence, 'and ready</a>'); |
| 193 | |
| 194 | const directory = convertPathsToLinks("Directory: /a0/usr/workdir is ready"); |
| 195 | assertIncludes(directory, 'data-path="/a0/usr/workdir"'); |
| 196 | """ |
| 197 | subprocess.run(["node", "-e", script], check=True, text=True) |