| 1 | import base64 |
| 2 | from pathlib import Path |
| 3 | import shutil |
| 4 | import subprocess |
| 5 | |
| 6 | import pytest |
| 7 | |
| 8 | |
| 9 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 10 | MESSAGE_WINDOW_JS = PROJECT_ROOT / "webui" / "js" / "message-window.js" |
| 11 | SCROLLER_JS = PROJECT_ROOT / "webui" / "js" / "scroller.js" |
| 12 | PROCESS_GROUP_DOM_JS = ( |
| 13 | PROJECT_ROOT |
| 14 | / "webui" |
| 15 | / "components" |
| 16 | / "messages" |
| 17 | / "process-group" |
| 18 | / "process-group-dom.js" |
| 19 | ) |
| 20 | MESSAGE_COLLAPSE_JS = PROJECT_ROOT / "webui" / "js" / "message-collapse.js" |
| 21 | |
| 22 | |
| 23 | def test_message_window_keeps_tail_and_pages_bidirectionally(): |
| 24 | if not shutil.which("node"): |
| 25 | pytest.skip("Node.js is required to execute the message-window regression.") |
| 26 | |
| 27 | source = MESSAGE_WINDOW_JS.read_bytes() |
| 28 | module_url = "data:text/javascript;base64," + base64.b64encode(source).decode("ascii") |
| 29 | script = f""" |
| 30 | import {{ MessageWindow, classifyMessageRenderUnits }} from {module_url!r}; |
| 31 | |
| 32 | function assert(condition, message) {{ |
| 33 | if (!condition) throw new Error(message); |
| 34 | }} |
| 35 | |
| 36 | const pluginBackedGroup = [ |
| 37 | {{ no: 1, id: "step-1", type: "info" }}, |
| 38 | {{ no: 2, id: "step-2", type: "agent" }}, |
| 39 | {{ no: 3, id: "step-3", type: "code_exe" }}, |
| 40 | {{ no: 4, id: "step-4", type: "agent" }}, |
| 41 | {{ no: 5, id: "step-5", type: "code_exe" }}, |
| 42 | {{ no: 6, id: "response-1", type: "response", agentno: 0 }}, |
| 43 | ]; |
| 44 | const pluginBackedUnits = classifyMessageRenderUnits(pluginBackedGroup); |
| 45 | assert( |
| 46 | pluginBackedUnits.every((unit) => unit.key === pluginBackedUnits[0].key), |
| 47 | "code execution records must remain inside their surrounding process group", |
| 48 | ); |
| 49 | assert( |
| 50 | pluginBackedUnits.filter((unit) => unit.isStep).length === 5, |
| 51 | "the root response must close the group without becoming a process step", |
| 52 | ); |
| 53 | |
| 54 | const prefixedUtilityGroup = [ |
| 55 | {{ no: 20, type: "util" }}, |
| 56 | {{ no: 21, type: "util" }}, |
| 57 | {{ no: 22, type: "agent", id: "agent-with-prefix" }}, |
| 58 | {{ no: 23, type: "response", id: "agent-with-prefix", agentno: 0 }}, |
| 59 | ]; |
| 60 | const prefixedUtilityUnits = classifyMessageRenderUnits(prefixedUtilityGroup); |
| 61 | assert( |
| 62 | prefixedUtilityUnits.every((unit) => unit.key === prefixedUtilityUnits[0].key), |
| 63 | "utilities immediately before a real process step must stay in that group", |
| 64 | ); |
| 65 | |
| 66 | const utilityOnlyResponse = [ |
| 67 | {{ no: 30, type: "util" }}, |
| 68 | {{ no: 31, type: "util" }}, |
| 69 | {{ no: 32, type: "response", id: "response-without-step", agentno: 0 }}, |
| 70 | {{ no: 33, type: "util" }}, |
| 71 | {{ no: 34, type: "user", id: "next-user" }}, |
| 72 | ]; |
| 73 | const utilityOnlyUnits = classifyMessageRenderUnits(utilityOnlyResponse); |
| 74 | assert( |
| 75 | utilityOnlyUnits.every((unit) => unit.group === null && !unit.isStep), |
| 76 | "orphan utilities must not create or reopen a process group around a response", |
| 77 | ); |
| 78 | |
| 79 | const sharedIdGroup = new MessageWindow({{ initialLimit: 60 }}); |
| 80 | const sharedIdKeys = sharedIdGroup.merge([ |
| 81 | {{ no: 1, id: "shared-run-id", type: "agent", content: "final generation" }}, |
| 82 | {{ no: 2, id: "shared-run-id", type: "response", content: "final response" }}, |
| 83 | ]); |
| 84 | assert( |
| 85 | sharedIdKeys.has("id:shared-run-id:type:response"), |
| 86 | "merge must report a newly added step", |
| 87 | ); |
| 88 | const updatedSharedIdKeys = sharedIdGroup.merge([ |
| 89 | {{ no: 2, id: "shared-run-id", type: "response", content: "updated response" }}, |
| 90 | ]); |
| 91 | assert( |
| 92 | updatedSharedIdKeys.size === 0, |
| 93 | "merge must not report an existing record update as newly added", |
| 94 | ); |
| 95 | assert(sharedIdGroup.size === 2, "a shared id must not merge GEN and response records"); |
| 96 | assert( |
| 97 | sharedIdGroup.visibleMessages().map((entry) => entry.type).join(",") === |
| 98 | "agent,response", |
| 99 | "replay must retain the final GEN immediately before its response", |
| 100 | ); |
| 101 | sharedIdGroup.merge([ |
| 102 | {{ no: 2, id: "shared-run-id", type: "response", content: "updated response" }}, |
| 103 | ]); |
| 104 | assert(sharedIdGroup.size === 2, "updates to one typed record must not duplicate it"); |
| 105 | assert( |
| 106 | sharedIdGroup.visibleMessages().at(-1).content === "updated response", |
| 107 | "typed cache keys must still replace updates to the same message", |
| 108 | ); |
| 109 | |
| 110 | const logs = Array.from({{ length: 1000 }}, (_, no) => ({{ |
| 111 | no, |
| 112 | type: no % 20 === 0 ? "user" : "tool", |
| 113 | content: `log-${{no}}`, |
| 114 | }})); |
| 115 | const windowed = new MessageWindow({{ initialLimit: 60, pageSize: 60, maxWindow: 120 }}); |
| 116 | windowed.reset(logs); |
| 117 | |
| 118 | assert(windowed.start === 940 && windowed.end === 1000, "initial render must start at the tail"); |
| 119 | assert(windowed.visibleMessages()[0].no === 940, "tail slice must be ordered"); |
| 120 | assert(windowed.olderCount === 940 && windowed.newerCount === 0, "tail counts must be accurate"); |
| 121 | |
| 122 | const unordered = new MessageWindow({{ initialLimit: 3, pageSize: 2, maxWindow: 4 }}); |
| 123 | unordered.reset([logs[2], logs[0], logs[1]]); |
| 124 | assert(unordered.visibleMessages().map((entry) => entry.no).join(",") === "0,1,2", "out-of-order records must be sorted once"); |
| 125 | |
| 126 | const groupedLogs = Array.from({{ length: 300 }}, (_, no) => ({{ |
| 127 | no, |
| 128 | unit: no >= 135 && no < 195 ? "large-process-group" : `entry-${{no}}`, |
| 129 | }})); |
| 130 | const groupedWindow = new MessageWindow({{ |
| 131 | initialLimit: 60, |
| 132 | pageSize: 60, |
| 133 | maxWindow: 120, |
| 134 | getUnitKeys: (messages) => messages.map((message) => message.unit), |
| 135 | }}); |
| 136 | groupedWindow.reset(groupedLogs); |
| 137 | groupedWindow.shiftOlder(); |
| 138 | assert(groupedWindow.visibleStart === 135 && groupedWindow.visibleEnd === 300, "a page boundary must expand to the complete process group"); |
| 139 | assert(groupedWindow.visibleMessages().filter((message) => message.unit === "large-process-group").length === 60, "a process group must never be split across the window"); |
| 140 | groupedWindow.shiftOlder(); |
| 141 | assert(groupedWindow.visibleStart === 120 && groupedWindow.visibleEnd === 240, "older paging must retain the whole intersecting group"); |
| 142 | groupedWindow.shiftNewer(); |
| 143 | assert(groupedWindow.visibleStart === 135 && groupedWindow.visibleEnd === 300, "newer paging must restore the whole-group tail range"); |
| 144 | |
| 145 | windowed.shiftOlder(); |
| 146 | assert(windowed.start === 880 && windowed.end === 1000, "first older page should retain the tail overlap"); |
| 147 | windowed.shiftOlder(); |
| 148 | assert(windowed.start === 820 && windowed.end === 940, "older paging must retain exactly the adjacent page"); |
| 149 | assert(windowed.hasOlder && windowed.hasNewer, "a historical window must page in both directions"); |
| 150 | |
| 151 | windowed.shiftNewer(); |
| 152 | assert(windowed.start === 880 && windowed.end === 1000, "newer paging must reverse the older-page swap exactly"); |
| 153 | assert(windowed.renderedCount === 120, "a shifted window must contain exactly two pages"); |
| 154 | |
| 155 | windowed.showHead(); |
| 156 | assert(windowed.start === 0 && windowed.end === 60, "the initial head view must contain one page"); |
| 157 | windowed.shiftNewer(); |
| 158 | assert(windowed.start === 0 && windowed.end === 120, "the first forward shift must retain page A and append page B"); |
| 159 | windowed.shiftNewer(); |
| 160 | assert(windowed.start === 60 && windowed.end === 180, "the second forward shift must retain B and append C"); |
| 161 | windowed.shiftNewer(); |
| 162 | assert(windowed.start === 120 && windowed.end === 240, "the third forward shift must retain C and append D"); |
| 163 | windowed.shiftOlder(); |
| 164 | assert(windowed.start === 60 && windowed.end === 180, "a reverse shift must restore B beside C"); |
| 165 | windowed.shiftOlder(); |
| 166 | assert(windowed.start === 0 && windowed.end === 120, "a second reverse shift must restore A beside B"); |
| 167 | |
| 168 | windowed.showTail(); |
| 169 | windowed.shiftOlder(); |
| 170 | assert(windowed.start === 880 && windowed.end === 1000, "tail paging must restore a two-page live window"); |
| 171 | |
| 172 | windowed.merge(Array.from({{ length: 20 }}, (_, offset) => ({{ |
| 173 | no: 1000 + offset, |
| 174 | type: "tool", |
| 175 | content: `new-${{offset}}`, |
| 176 | }}))); |
| 177 | assert(windowed.end === 1020, "live tail appends must remain visible"); |
| 178 | assert(windowed.compactTailIfNeeded(), "an oversized live window must compact"); |
| 179 | assert(windowed.start === 900 && windowed.end === 1020, "compaction must retain two complete tail pages"); |
| 180 | assert(windowed.renderedCount === 120, "tail compaction must use the same two-page bound"); |
| 181 | |
| 182 | windowed.merge([{{ no: 1019, type: "response", content: "updated" }}]); |
| 183 | const updated = windowed.visibleMessages().at(-1); |
| 184 | assert(updated.type === "response" && updated.content === "updated", "existing log updates must replace cached data"); |
| 185 | |
| 186 | windowed.reset(logs); |
| 187 | windowed.merge([{{ no: 1000, type: "tool", content: "unread" }}], {{ followTail: false }}); |
| 188 | assert(windowed.end === 1000, "an unfollowed live append must not move the visible window"); |
| 189 | assert(windowed.newerCount === 1, "an unfollowed live append must remain available as a newer page"); |
| 190 | """ |
| 191 | subprocess.run( |
| 192 | ["node", "--input-type=module", "-e", script], |
| 193 | check=True, |
| 194 | text=True, |
| 195 | ) |
| 196 | |
| 197 | |
| 198 | def test_warning_replay_prefers_classified_process_group(): |
| 199 | messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text( |
| 200 | encoding="utf-8" |
| 201 | ) |
| 202 | warning_handler = messages.split( |
| 203 | "export function drawMessageWarning", maxsplit=1 |
| 204 | )[1].split("export function drawMessageError", maxsplit=1)[0] |
| 205 | |
| 206 | assert "arguments[0][PROCESS_GROUP_RENDER_INFO]" in warning_handler |
| 207 | assert "getLastProcessGroup(false)" in warning_handler |
| 208 | |
| 209 | |
| 210 | def test_collapsed_process_details_are_deferred_and_discarded(): |
| 211 | messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text( |
| 212 | encoding="utf-8" |
| 213 | ) |
| 214 | process_group_dom = ( |
| 215 | PROJECT_ROOT |
| 216 | / "webui" |
| 217 | / "components" |
| 218 | / "messages" |
| 219 | / "process-group" |
| 220 | / "process-group-dom.js" |
| 221 | ).read_text(encoding="utf-8") |
| 222 | |
| 223 | assert "detailPending: !shouldRenderDetail" in messages |
| 224 | assert "discardProcessStepDetail(step)" in messages |
| 225 | assert 'step.__renderDetail !== "function"' in messages |
| 226 | assert "estimateKvpTextSize(kvps)" in messages |
| 227 | assert "kvps: expanded ? kvps : null" in messages |
| 228 | assert "await Promise.allSettled(pending)" in process_group_dom |
| 229 | assert "step.__setExpanded(shouldExpandStep)" in process_group_dom |
| 230 | |
| 231 | |
| 232 | def test_user_messages_share_collapse_behavior_without_clipping_attachments(): |
| 233 | messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text( |
| 234 | encoding="utf-8" |
| 235 | ) |
| 236 | message_css = (PROJECT_ROOT / "webui" / "css" / "messages.css").read_text( |
| 237 | encoding="utf-8" |
| 238 | ) |
| 239 | action_button_css = ( |
| 240 | PROJECT_ROOT |
| 241 | / "webui" |
| 242 | / "components" |
| 243 | / "messages" |
| 244 | / "action-buttons" |
| 245 | / "simple-action-buttons.css" |
| 246 | ).read_text(encoding="utf-8") |
| 247 | |
| 248 | assert 'contentSelector = ":scope > .message-body"' in messages |
| 249 | assert 'collapseContent?.classList.add("message-collapse-content")' in messages |
| 250 | assert 'refreshCollapsibleMessageOverflow(history)' in messages |
| 251 | assert 'refreshCollapsibleMessageOverflow(entry.target)' in messages |
| 252 | assert 'measureMessageCollapseOverflow(collapseContent' in messages |
| 253 | assert '":scope > .message-text",\n );' in messages |
| 254 | assert "attachmentsContainer.classList.add(\"attachments-container\")" in messages |
| 255 | assert ".message.message-collapsible .message-collapse-content" in message_css |
| 256 | assert ".message.message-agent-response.message-collapsible" in message_css |
| 257 | assert ".attachments-container.message-collapse-content" not in message_css |
| 258 | assert ".message-user .step-action-buttons .expand-btn" in action_button_css |
| 259 | assert "order: 1" in action_button_css |
| 260 | |
| 261 | |
| 262 | def test_message_collapse_ignores_hidden_replay_geometry_and_real_short_text(): |
| 263 | if not shutil.which("node"): |
| 264 | pytest.skip("Node.js is required to execute the collapse regression.") |
| 265 | |
| 266 | source = MESSAGE_COLLAPSE_JS.read_bytes() |
| 267 | module_url = "data:text/javascript;base64," + base64.b64encode(source).decode( |
| 268 | "ascii" |
| 269 | ) |
| 270 | script = f""" |
| 271 | import {{ measureMessageCollapseOverflow }} from {module_url!r}; |
| 272 | |
| 273 | function assert(condition, message) {{ |
| 274 | if (!condition) throw new Error(message); |
| 275 | }} |
| 276 | |
| 277 | let historyWidth = 0; |
| 278 | const history = {{ |
| 279 | get clientWidth() {{ return historyWidth || 24; }}, |
| 280 | }}; |
| 281 | const content = {{ |
| 282 | isConnected: true, |
| 283 | clientWidth: 210, |
| 284 | clientHeight: 34, |
| 285 | scrollHeight: 280, |
| 286 | getBoundingClientRect: () => ({{ width: 210 }}), |
| 287 | closest: (selector) => selector === "#chat-history" ? history : null, |
| 288 | }}; |
| 289 | globalThis.getComputedStyle = (element) => ({{ |
| 290 | fontSize: "16px", |
| 291 | paddingLeft: element === history ? "12px" : "0px", |
| 292 | paddingRight: element === history ? "12px" : "0px", |
| 293 | }}); |
| 294 | |
| 295 | assert( |
| 296 | measureMessageCollapseOverflow(content) === null, |
| 297 | "zero-width replay staging must not mark short text as overflowing", |
| 298 | ); |
| 299 | |
| 300 | historyWidth = 800; |
| 301 | content.scrollHeight = 34; |
| 302 | assert( |
| 303 | measureMessageCollapseOverflow(content) === false, |
| 304 | "a laid-out one-line message must not expose Show More", |
| 305 | ); |
| 306 | |
| 307 | content.clientHeight = 240; |
| 308 | content.scrollHeight = 420; |
| 309 | assert( |
| 310 | measureMessageCollapseOverflow(content) === true, |
| 311 | "a body taller than the collapsed preview must expose Show More", |
| 312 | ); |
| 313 | assert( |
| 314 | measureMessageCollapseOverflow(content, {{ expanded: true }}) === true, |
| 315 | "expanded long bodies must retain their Show Less control", |
| 316 | ); |
| 317 | """ |
| 318 | subprocess.run( |
| 319 | ["node", "--input-type=module", "-e", script], |
| 320 | check=True, |
| 321 | text=True, |
| 322 | ) |
| 323 | |
| 324 | |
| 325 | def test_process_groups_are_atomic_and_page_steps_in_fifties(): |
| 326 | messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text( |
| 327 | encoding="utf-8" |
| 328 | ) |
| 329 | group_css = ( |
| 330 | PROJECT_ROOT |
| 331 | / "webui" |
| 332 | / "components" |
| 333 | / "messages" |
| 334 | / "process-group" |
| 335 | / "process-group.css" |
| 336 | ).read_text(encoding="utf-8") |
| 337 | |
| 338 | assert "const PROCESS_GROUP_STEP_PAGE_SIZE = 50" in messages |
| 339 | assert 'classifyMessageRenderUnits(messages)' in messages |
| 340 | assert '"code_exe",' in MESSAGE_WINDOW_JS.read_text(encoding="utf-8") |
| 341 | assert "getUnitKeys: getMessageRenderUnitKeys" in messages |
| 342 | assert "getProcessGroupRenderMessages(windowMessages)" in messages |
| 343 | assert "const addedMessageKeys = _messageWindow.merge" in messages |
| 344 | assert "addedMessageKeys.has(getMessageCacheKey(message))" in messages |
| 345 | assert 'button.className = "process-group-show-more"' in messages |
| 346 | assert "current + PROCESS_GROUP_STEP_PAGE_SIZE" in messages |
| 347 | assert "group.dataset.fullStartTimestamp" in messages |
| 348 | assert 'else if (log.type === "util")' in messages |
| 349 | assert 'group?.classList.contains("utility-only")' in messages |
| 350 | assert "allowCompletedGroup: false" in messages |
| 351 | assert ".process-group.utility-only {" in group_css |
| 352 | assert ".show-utility-messages .process-group.utility-only" in group_css |
| 353 | assert ".process-group.utility-only[hidden]" not in group_css |
| 354 | assert ".process-group-show-more" in group_css |
| 355 | show_more_css = group_css.split(".process-group-show-more {", 1)[1].split( |
| 356 | "}", 1 |
| 357 | )[0] |
| 358 | assert "text-decoration: none" in show_more_css |
| 359 | assert "opacity: 0.7" in show_more_css |
| 360 | assert "text-decoration: underline" not in show_more_css |
| 361 | |
| 362 | |
| 363 | def test_detail_preferences_await_materialization_and_select_current_step(): |
| 364 | if not shutil.which("node"): |
| 365 | pytest.skip("Node.js is required to execute the detail-mode regression.") |
| 366 | |
| 367 | source = PROCESS_GROUP_DOM_JS.read_text(encoding="utf-8").replace( |
| 368 | 'import { store as preferencesStore } from ' |
| 369 | '"/components/sidebar/bottom/preferences/preferences-store.js";\n', |
| 370 | 'const preferencesStore = { detailMode: "current", showUtils: false };\n', |
| 371 | ) |
| 372 | module_url = "data:text/javascript;base64," + base64.b64encode( |
| 373 | source.encode("utf-8") |
| 374 | ).decode("ascii") |
| 375 | script = f""" |
| 376 | import {{ applyModeSteps }} from {module_url!r}; |
| 377 | |
| 378 | function assert(condition, message) {{ |
| 379 | if (!condition) throw new Error(message); |
| 380 | }} |
| 381 | |
| 382 | function makeClasses(initial = []) {{ |
| 383 | const values = new Set(initial); |
| 384 | return {{ |
| 385 | contains: (name) => values.has(name), |
| 386 | toggle(name, force) {{ |
| 387 | if (force) values.add(name); |
| 388 | else values.delete(name); |
| 389 | }}, |
| 390 | }}; |
| 391 | }} |
| 392 | |
| 393 | function makeStep(name, util = false) {{ |
| 394 | const step = {{ |
| 395 | name, |
| 396 | classList: makeClasses(util ? ["message-util"] : []), |
| 397 | detailReady: false, |
| 398 | }}; |
| 399 | step.__setExpanded = (expanded) => new Promise((resolve) => {{ |
| 400 | queueMicrotask(() => {{ |
| 401 | step.classList.toggle("expanded", expanded); |
| 402 | step.detailReady = expanded; |
| 403 | resolve(); |
| 404 | }}); |
| 405 | }}); |
| 406 | return step; |
| 407 | }} |
| 408 | |
| 409 | function makeGroup(steps, complete = false) {{ |
| 410 | const group = {{ |
| 411 | steps, |
| 412 | complete, |
| 413 | classList: makeClasses(), |
| 414 | hasAttribute: (name) => complete && name === "data-group-complete", |
| 415 | querySelector: (selector) => |
| 416 | complete && selector === ".process-group-response" ? {{}} : null, |
| 417 | querySelectorAll: (selector) => selector === ".process-step" ? steps : [], |
| 418 | }}; |
| 419 | group.__setExpanded = async (expanded) => {{ |
| 420 | group.classList.toggle("expanded", expanded); |
| 421 | }}; |
| 422 | return group; |
| 423 | }} |
| 424 | |
| 425 | const completedSteps = [makeStep("old")]; |
| 426 | const currentSteps = [makeStep("first"), makeStep("current"), makeStep("util", true)]; |
| 427 | const groups = [makeGroup(completedSteps, true), makeGroup(currentSteps)]; |
| 428 | const history = {{ |
| 429 | dataset: {{ messageWindowEnd: "100", messageWindowTotal: "100" }}, |
| 430 | querySelectorAll: (selector) => selector === ".process-group" ? groups : [], |
| 431 | }}; |
| 432 | globalThis.document = {{ |
| 433 | getElementById: (id) => id === "chat-history" ? history : null, |
| 434 | }}; |
| 435 | |
| 436 | await applyModeSteps("expanded", false); |
| 437 | assert( |
| 438 | [...completedSteps, ...currentSteps].every((step) => step.detailReady), |
| 439 | "ALL must await every visible step detail", |
| 440 | ); |
| 441 | |
| 442 | await applyModeSteps("collapsed", false); |
| 443 | assert( |
| 444 | [...completedSteps, ...currentSteps].every((step) => !step.detailReady), |
| 445 | "NO must collapse every visible step", |
| 446 | ); |
| 447 | |
| 448 | await applyModeSteps("current", false); |
| 449 | assert(!completedSteps[0].detailReady, "STEP must not open completed history"); |
| 450 | assert(!currentSteps[0].detailReady, "STEP must collapse older active steps"); |
| 451 | assert(currentSteps[1].detailReady, "STEP must materialize the current visible step"); |
| 452 | assert(!currentSteps[2].detailReady, "hidden utility steps must not become current"); |
| 453 | |
| 454 | history.dataset.messageWindowEnd = "50"; |
| 455 | await applyModeSteps("current", false); |
| 456 | assert( |
| 457 | currentSteps.every((step) => !step.detailReady), |
| 458 | "STEP must not treat a historical window boundary as the live current step", |
| 459 | ); |
| 460 | """ |
| 461 | subprocess.run( |
| 462 | ["node", "--input-type=module", "-e", script], |
| 463 | check=True, |
| 464 | text=True, |
| 465 | ) |
| 466 | |
| 467 | |
| 468 | def test_virtual_rebuild_cancels_stale_scroller_effects(): |
| 469 | if not shutil.which("node"): |
| 470 | pytest.skip("Node.js is required to execute the scroller regression.") |
| 471 | |
| 472 | source = SCROLLER_JS.read_bytes() |
| 473 | module_url = "data:text/javascript;base64," + base64.b64encode(source).decode( |
| 474 | "ascii" |
| 475 | ) |
| 476 | script = f""" |
| 477 | import {{ cancelPendingScroll }} from {module_url!r}; |
| 478 | |
| 479 | function assert(condition, message) {{ |
| 480 | if (!condition) throw new Error(message); |
| 481 | }} |
| 482 | |
| 483 | let delayedScrollRan = false; |
| 484 | const timeoutId = setTimeout(() => {{ delayedScrollRan = true; }}, 30); |
| 485 | const element = {{ |
| 486 | dataset: {{ |
| 487 | scrollerTimeout: String(Number(timeoutId)), |
| 488 | scrollerReapplySnapshot: "200", |
| 489 | scrollingTo: "900", |
| 490 | }}, |
| 491 | scrollTop: 240, |
| 492 | scrollCalls: [], |
| 493 | scrollTo(options) {{ this.scrollCalls.push(options); }}, |
| 494 | }}; |
| 495 | |
| 496 | cancelPendingScroll(element); |
| 497 | await new Promise((resolve) => setTimeout(resolve, 60)); |
| 498 | |
| 499 | assert(!delayedScrollRan, "a stale delayed auto-scroll must be canceled"); |
| 500 | assert(element.scrollCalls.length === 1, "an in-flight smooth scroll must be stopped"); |
| 501 | assert(element.scrollCalls[0].top === 240, "canceling must retain the current offset"); |
| 502 | assert(!("scrollerTimeout" in element.dataset), "timeout state must be cleared"); |
| 503 | assert(!("scrollingTo" in element.dataset), "smooth-scroll state must be cleared"); |
| 504 | """ |
| 505 | subprocess.run( |
| 506 | ["node", "--input-type=module", "-e", script], |
| 507 | check=True, |
| 508 | text=True, |
| 509 | ) |
| 510 | |
| 511 | |
| 512 | def test_virtual_window_preserves_live_and_navigation_contracts(): |
| 513 | messages = (PROJECT_ROOT / "webui" / "js" / "messages.js").read_text( |
| 514 | encoding="utf-8" |
| 515 | ) |
| 516 | message_css = (PROJECT_ROOT / "webui" / "css" / "messages.css").read_text( |
| 517 | encoding="utf-8" |
| 518 | ) |
| 519 | navigation = ( |
| 520 | PROJECT_ROOT |
| 521 | / "webui" |
| 522 | / "components" |
| 523 | / "chat" |
| 524 | / "navigation" |
| 525 | / "chat-navigation-store.js" |
| 526 | ).read_text(encoding="utf-8") |
| 527 | |
| 528 | assert "_messageRenderGeneration" in messages |
| 529 | assert "result: { element: null, virtualized: true, dontScroll: true }" in messages |
| 530 | assert 'scrollMessageWindowToEdge("start")' in navigation |
| 531 | assert 'scrollMessageWindowToEdge("end")' in navigation |
| 532 | assert 'loadAdjacentMessageWindow("older")' in navigation |
| 533 | assert 'loadAdjacentMessageWindow("newer")' in navigation |
| 534 | assert "_messageWindowFollowTail" in messages |
| 535 | assert "hasUserScrollIntent" in messages |
| 536 | assert "cancelPendingScroll(history)" in messages |
| 537 | assert "createMessageWindowStagingHistory(history)" in messages |
| 538 | assert "history.replaceChildren(...stagedChildren)" in messages |
| 539 | assert 'element.classList.add("message-window-restored")' in messages |
| 540 | assert "createMessageWindowIndicator" in messages |
| 541 | assert 'document.createElement("div")' in messages |
| 542 | assert "Loading ${label} messages" in messages |
| 543 | assert "Load ${Math.min" not in messages |
| 544 | assert "overflow-anchor: none" in message_css |
| 545 | assert ".message-container.message-window-restored" in message_css |