main
py 197 lines 8.21 KB
Raw
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)