= LazyLock::new(|| {
// checkboxes produced by pulldown-cmark) and `iframe` (embedded media
// inserted via TinyMCE), neither of which Ammonia allows by default.
b.add_tags(&["wc-mermaid", "graphviz-graph", "input", "iframe"])
+ // Allow any element to be assigned an ID.
+ .add_generic_attributes(&["id"])
// This allows math produced by pulldown-cmark and updated by the
// hydration code.
.add_allowed_classes(
diff --git a/server/src/processing/tests.rs b/server/src/processing/tests.rs
index 3855f54e..7086cad3 100644
--- a/server/src/processing/tests.rs
+++ b/server/src/processing/tests.rs
@@ -783,6 +783,33 @@ fn test_source_to_codechat_for_web_1() {
]
)))
);
+
+ // Test that minify functions correctly across multiple paragraphs separated
+ // by a code block.
+ assert_eq!(
+ source_to_codechat_for_web(
+ indoc!(
+ r#"
+ // 1
+ "#
+ ),
+ &"cpp".to_string(),
+ 0.0,
+ false,
+ false
+ ),
+ Ok(TranslationResults::CodeChat(build_codechat_for_web(
+ "cpp",
+ "\n",
+ vec![build_codemirror_doc_block(
+ 0,
+ 1,
+ "",
+ "//",
+ r#"1"#
+ ),]
+ )))
+ );
}
#[test]
diff --git a/server/tests/fixtures/overall_1/test_client_updates/test.py b/server/tests/fixtures/overall_1/test_updates/test.py
similarity index 100%
rename from server/tests/fixtures/overall_1/test_client_updates/test.py
rename to server/tests/fixtures/overall_1/test_updates/test.py
diff --git a/server/tests/fixtures/overall_1/test_client_updates/toc.md b/server/tests/fixtures/overall_1/test_updates/toc.md
similarity index 100%
rename from server/tests/fixtures/overall_1/test_client_updates/toc.md
rename to server/tests/fixtures/overall_1/test_updates/toc.md
diff --git a/server/tests/fixtures/overall_4/test_arrow_key_navigation/test.py b/server/tests/fixtures/overall_4/test_arrow_key_navigation/test.py
new file mode 100644
index 00000000..3b1386f5
--- /dev/null
+++ b/server/tests/fixtures/overall_4/test_arrow_key_navigation/test.py
@@ -0,0 +1,3 @@
+The contents of this file don't matter -- tests will supply the content,
+instead of loading it from disk. However, it does need to exist for
+`canonicalize` to find the correct path to this file.
diff --git a/server/tests/fixtures/overall_4/test_arrow_key_navigation_multiline_doc_block/test.py b/server/tests/fixtures/overall_4/test_arrow_key_navigation_multiline_doc_block/test.py
new file mode 100644
index 00000000..3b1386f5
--- /dev/null
+++ b/server/tests/fixtures/overall_4/test_arrow_key_navigation_multiline_doc_block/test.py
@@ -0,0 +1,3 @@
+The contents of this file don't matter -- tests will supply the content,
+instead of loading it from disk. However, it does need to exist for
+`canonicalize` to find the correct path to this file.
diff --git a/server/tests/fixtures/overall_4/test_horizontal_scroll_preserved/test.py b/server/tests/fixtures/overall_4/test_horizontal_scroll_preserved/test.py
new file mode 100644
index 00000000..3b1386f5
--- /dev/null
+++ b/server/tests/fixtures/overall_4/test_horizontal_scroll_preserved/test.py
@@ -0,0 +1,3 @@
+The contents of this file don't matter -- tests will supply the content,
+instead of loading it from disk. However, it does need to exist for
+`canonicalize` to find the correct path to this file.
diff --git a/server/tests/fixtures/overall_5/test_cursor_home_from_code_after_doc_block/test.py b/server/tests/fixtures/overall_5/test_cursor_home_from_code_after_doc_block/test.py
new file mode 100644
index 00000000..3b1386f5
--- /dev/null
+++ b/server/tests/fixtures/overall_5/test_cursor_home_from_code_after_doc_block/test.py
@@ -0,0 +1,3 @@
+The contents of this file don't matter -- tests will supply the content,
+instead of loading it from disk. However, it does need to exist for
+`canonicalize` to find the correct path to this file.
diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs
index 4c6fc8a9..9b223351 100644
--- a/server/tests/overall_1.rs
+++ b/server/tests/overall_1.rs
@@ -19,6 +19,9 @@
/// These are functional tests of the overall system, performed by attaching a
/// testing IDE to generate commands then observe results, along with a browser
/// tester.
+///
+/// To run this test, execute `cargo test --test overall_1
+/// ` in the `server/` directory.
// Modules
// -------
mod overall_common;
@@ -38,8 +41,9 @@ use tokio::time::sleep;
// ### Local
use crate::overall_common::{
- CodeChatEditorServerLog, ExpectedMessages, TIMEOUT, assert_no_more_messages, get_version,
- goto_line, optional_message, perform_loadfile, select_codechat_iframe,
+ CodeChatEditorServerLog, ExpectedMessages, TIMEOUT, assert_no_more_messages, beginning_of_line,
+ end_of_line, get_version, goto_line, optional_message, perform_loadfile,
+ select_codechat_iframe,
};
use code_chat_editor::{
lexer::supported_languages::MARKDOWN_MODE,
@@ -175,7 +179,11 @@ async fn test_server_core(
client_id += MESSAGE_ID_INCREMENT;
// Edit the indent. It should only allow spaces and tabs, rejecting other
- // edits.
+ // edits. Click it first, since the indent is only editable while
+ // focused; see the inline `onmousedown` handler on
+ // `.CodeChat-doc-indent` in `CodeMirror-integration.mts`, which makes it
+ // editable on click.
+ doc_block_indent.click().await.unwrap();
doc_block_indent.send_keys(" 123").await.unwrap();
let msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap();
let client_version = get_version(&msg);
@@ -236,7 +244,7 @@ async fn test_server_core(
client_id += MESSAGE_ID_INCREMENT;
// Moving left should move us back to the doc block.
- code_line.send_keys(Key::Home + Key::Left).await.unwrap();
+ beginning_of_line(&code_line, Key::Left).await.unwrap();
assert_eq!(
codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
EditorMessage {
@@ -729,9 +737,9 @@ async fn test_client_core(
Ok(())
}
-make_test!(test_client_updates, test_client_updates_core);
+make_test!(test_updates, test_updates_core);
-async fn test_client_updates_core(
+async fn test_updates_core(
codechat_server: CodeChatEditorServerLog,
driver: WebDriver,
test_dir: PathBuf,
@@ -784,10 +792,7 @@ async fn test_client_updates_core(
client_id += MESSAGE_ID_INCREMENT;
let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap();
- doc_block_contents
- .send_keys(Key::End + " testing")
- .await
- .unwrap();
+ end_of_line(&doc_block_contents, " testing").await.unwrap();
// Get the next message, which could be a cursor update followed by a text
// update, or just the text update.
@@ -865,7 +870,7 @@ async fn test_client_updates_core(
// Add an indented comment.
let code_line_css = ".CodeChat-CodeMirror .cm-line";
let code_line = driver.find(By::Css(code_line_css)).await.unwrap();
- code_line.send_keys(Key::Home + "# ").await.unwrap();
+ beginning_of_line(&code_line, "# ").await.unwrap();
// This should edit the (new) third line of the file after word wrap: `def
// foo():`.
let msg = optional_message(
diff --git a/server/tests/overall_2.rs b/server/tests/overall_2.rs
index 2acd2322..69dce212 100644
--- a/server/tests/overall_2.rs
+++ b/server/tests/overall_2.rs
@@ -33,12 +33,13 @@ use std::path::PathBuf;
use dunce::canonicalize;
use indoc::indoc;
use pretty_assertions::assert_eq;
-use thirtyfour::{By, Key, WebDriver, error::WebDriverError};
+use thirtyfour::{By, WebDriver, error::WebDriverError, extensions::query::ElementQueryable};
// ### Local
use crate::overall_common::{
- CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, click_element_top_left, get_version,
- optional_message, perform_loadfile, select_codechat_iframe,
+ CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, beginning_of_document,
+ click_element_top_left, get_version, optional_message, perform_loadfile,
+ select_codechat_iframe,
};
use code_chat_editor::{
processing::{
@@ -228,7 +229,7 @@ async fn test_5_core(
assert_eq!(client_id, 7.0);
// Refind it, since it's now switched with a TinyMCE editor.
- let tinymce_contents = driver.find(By::Id("TinyMCE-inst")).await.unwrap();
+ let tinymce_contents = driver.query(By::Id("TinyMCE-inst")).first().await.unwrap();
// Make an edit.
tinymce_contents.send_keys("foo").await.unwrap();
@@ -403,39 +404,10 @@ async fn test_6_core(
codechat_server.send_result(client_id, None).await.unwrap();
client_id += MESSAGE_ID_INCREMENT;
- // Perform edits at beginning of document. Go to the beginning of the
- // document, using an OS-specific key combo. On MacOS, Home, Command+Up, and
- // Command+Home all fail. Here's a kludgy workaround: press the up arrow
- // repeatedly.
- //
- // Background: On macOS, many standard navigation shortcuts (like Cmd + Up
- // or Ctrl + Home) are intercepted at the OS or application-shell level
- // rather than by the web browser's DOM.\
- // Because ChromeDriver simulates interactions by injecting DOM-level events
- // (keyup/keydown), these injected events bypass macOS’s native Cocoa text
- // system. As a result, the OS does not trigger the expected cursor or
- // document jumps.
- //
- // Here is how the system handles these keys and how to work around the
- // limitation:
- //
- // * The WebKit/Cocoa Bridge: On Mac, many text field interactions rely on
- // macOS's global key bindings (managed by NSTextView in the Cocoa
- // framework).
- // * OS Interception: Shortcuts like Cmd + Up (beginning of document) and
- // Ctrl + Home trigger OS-level text editing behaviors rather than pure
- // JavaScript DOM events.
- // * WebDriver Limitation: WebDriver strictly injects key events into the
- // browser's JavaScript execution context. Since OS-level shortcuts are
- // intercepted by the native application frame, send\_keys() in a testing
- // script often fails to trigger the OS-level jump.
- #[cfg(target_os = "macos")]
- body_content.send_keys(Key::Up + Key::Up).await.unwrap();
- #[cfg(not(target_os = "macos"))]
- body_content
- .send_keys(Key::Control + Key::Home)
- .await
- .unwrap();
+ // Perform edits at the beginning of the document. See
+ // `overall_common::beginning_of_document` for why this can't just be a
+ // plain OS-specific key combo on macOS.
+ beginning_of_document(&body_content, "").await.unwrap();
body_content.send_keys("a").await.unwrap();
// Sometimes, a cursor update gets sent before the edit.
let msg = optional_message(
diff --git a/server/tests/overall_3.rs b/server/tests/overall_3.rs
index 1d5f943f..2e022f7e 100644
--- a/server/tests/overall_3.rs
+++ b/server/tests/overall_3.rs
@@ -37,8 +37,9 @@ use thirtyfour::{By, Key, WebDriver, error::WebDriverError, extensions::query::E
// ### Local
use crate::overall_common::{
- CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, click_element_top_left, get_version,
- optional_message, perform_loadfile, select_codechat_iframe,
+ CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, beginning_of_document,
+ beginning_of_line, click_element_top_left, get_version, optional_message, perform_loadfile,
+ select_codechat_iframe,
};
use code_chat_editor::{
processing::{
@@ -91,9 +92,15 @@ async fn test_7_core(
select_codechat_iframe(&driver).await;
// Focus the doc block. It should produce an update with only cursor/scroll
- // info (no contents).
+ // info (no contents). Click on the contents specifically (rather than the
+ // whole doc block, which also contains the indent) so the click reliably
+ // lands inside the text regardless of the indent's width, which varies
+ // with its `contenteditable` state.
let mut client_id = INITIAL_CLIENT_MESSAGE_ID;
- let doc_block = driver.find(By::Css(".CodeChat-doc")).await.unwrap();
+ let doc_block = driver
+ .find(By::Css(".CodeChat-doc-contents"))
+ .await
+ .unwrap();
click_element_top_left(&driver, &doc_block).await.unwrap();
assert_eq!(
codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
@@ -142,7 +149,7 @@ async fn test_7_core(
make_test!(test_8, test_8_core);
-// Test that Clients can insert a new paragraph.
+// Test that Clients can insert a new paragraph; do so at the beginning of a doc block, in the middle of a doc block, and at the end of a doc block.
async fn test_8_core(
codechat_server: CodeChatEditorServerLog,
driver: WebDriver,
@@ -175,9 +182,15 @@ async fn test_8_core(
select_codechat_iframe(&driver).await;
// Focus the doc block. It should produce an update with only cursor/scroll
- // info (no contents).
+ // info (no contents). Click on the contents specifically (rather than the
+ // whole doc block, which also contains the indent) so the click reliably
+ // lands inside the text regardless of the indent's width, which varies
+ // with its `contenteditable` state.
let mut client_id = INITIAL_CLIENT_MESSAGE_ID;
- let doc_block = driver.find(By::Css(".CodeChat-doc")).await.unwrap();
+ let doc_block = driver
+ .find(By::Css(".CodeChat-doc-contents"))
+ .await
+ .unwrap();
click_element_top_left(&driver, &doc_block).await.unwrap();
assert_eq!(
@@ -199,19 +212,7 @@ async fn test_8_core(
// Refind it, since it's now switched with a TinyMCE editor.
let tinymce_contents = driver.find(By::Id("TinyMCE-inst")).await.unwrap();
- // Move to the beginning of this line. Due to MacOS fun, avoid option+left
- // arrow. TODO: the cursor movement doesn't seem to change the actual
- // insertion point. Not sure why.
- tinymce_contents
- .send_keys(Key::Left + Key::Left)
- .await
- .unwrap();
-
- // Uncomment for debug.
- //use std::time::Duration;
- //use tokio::time::sleep;
- //sleep(Duration::from_hours(1)).await;
-
+ beginning_of_line(&tinymce_contents, "").await.unwrap();
assert_eq!(
codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
EditorMessage {
@@ -229,6 +230,8 @@ async fn test_8_core(
client_id += MESSAGE_ID_INCREMENT;
// Start a new paragraph. Wait for a re-translation as the line changes.
+ // The click above lands the cursor right at the start of "2" (the first
+ // paragraph), so this splits it into an empty paragraph followed by "2".
tinymce_contents.send_keys(Key::Enter).await.unwrap();
let msg = optional_message(
@@ -251,7 +254,7 @@ async fn test_8_core(
id: client_id,
message: EditorMessageContents::Update(UpdateMessageContents {
file_path: path_str.clone(),
- cursor_position: Some(CursorPosition::Line(1)),
+ cursor_position: Some(CursorPosition::Line(3)),
scroll_position: Some(1.0),
is_re_translation: false,
contents: Some(CodeChatForWeb {
@@ -260,9 +263,9 @@ async fn test_8_core(
},
source: CodeMirrorDiffable::Diff(CodeMirrorDiff {
doc: vec![StringDiff {
- from: 10,
+ from: 0,
to: None,
- insert: "#\n# \u{a0}\n".to_string(),
+ insert: "# \u{a0}\n#\n".to_string(),
},],
doc_blocks: vec![],
version,
@@ -293,7 +296,7 @@ async fn test_8_core(
id: client_id,
message: EditorMessageContents::Update(UpdateMessageContents {
file_path: path_str.clone(),
- cursor_position: Some(CursorPosition::Line(1)),
+ cursor_position: Some(CursorPosition::Line(3)),
scroll_position: Some(1.0),
is_re_translation: false,
contents: None,
@@ -305,11 +308,11 @@ async fn test_8_core(
// ### Insert a newline between two existing paragraphs
//
- // After the previous edit, the doc block contains three paragraphs. Move up
- // to the first paragraph (producing a cursor-only update), then start a new
- // paragraph between the first and second ones. Wait for a re-translation as
- // the lines change.
- tinymce_contents.send_keys(Key::Up + Key::Up).await.unwrap();
+ // After the previous edit, the doc block contains three paragraphs (an
+ // empty one, "2", and "4"). Move up to the first (empty) paragraph
+ // (producing a cursor-only update), then start a new paragraph there.
+ // Wait for a re-translation as the lines change.
+ beginning_of_document(&tinymce_contents, "").await.unwrap();
tinymce_contents.send_keys(Key::Enter).await.unwrap();
// The cursor move produces an optional cursor-only update before the
@@ -333,7 +336,7 @@ async fn test_8_core(
id: client_id,
message: EditorMessageContents::Update(UpdateMessageContents {
file_path: path_str.clone(),
- cursor_position: Some(CursorPosition::Line(3)),
+ cursor_position: Some(CursorPosition::Line(1)),
scroll_position: Some(1.0),
is_re_translation: false,
contents: Some(CodeChatForWeb {
@@ -342,7 +345,7 @@ async fn test_8_core(
},
source: CodeMirrorDiffable::Diff(CodeMirrorDiff {
doc: vec![StringDiff {
- from: 0,
+ from: 6,
to: None,
insert: "# \u{a0}\n#\n".to_string(),
},],
@@ -375,7 +378,7 @@ async fn test_8_core(
id: client_id,
message: EditorMessageContents::Update(UpdateMessageContents {
file_path: path_str.clone(),
- cursor_position: Some(CursorPosition::Line(3)),
+ cursor_position: Some(CursorPosition::Line(1)),
scroll_position: Some(1.0),
is_re_translation: false,
contents: None,
@@ -503,10 +506,13 @@ async fn test_9_core(
select_codechat_iframe(&driver).await;
// Focus the doc block. It should produce an update with only cursor/scroll
- // info (no contents).
+ // info (no contents). Click on the contents specifically (rather than the
+ // whole doc block, which also contains the indent) so the click reliably
+ // lands inside the text regardless of the indent's width, which varies
+ // with its `contenteditable` state.
let mut client_id = INITIAL_CLIENT_MESSAGE_ID;
let doc_block = driver
- .query(By::Css(".CodeChat-doc"))
+ .query(By::Css(".CodeChat-doc-contents"))
.first()
.await
.unwrap();
@@ -531,6 +537,27 @@ async fn test_9_core(
// Refind it, since it's now switched with a TinyMCE editor.
let tinymce_contents = driver.find(By::Id("TinyMCE-inst")).await.unwrap();
+ // The click above doesn't necessarily land exactly at the start of the
+ // text (its position now depends on the contents div's layout, not the
+ // indent's), so explicitly move to the beginning of the line before
+ // editing.
+ beginning_of_line(&tinymce_contents, "").await.unwrap();
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(1)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
// Perform an edit
tinymce_contents.send_keys("a").await.unwrap();
@@ -579,7 +606,9 @@ async fn test_9_core(
codechat_server.send_result(client_id, None).await.unwrap();
client_id += MESSAGE_ID_INCREMENT;
- // Focus on the code block.
+ // Focus on the code block. Both source lines are doc blocks (kept
+ // separate by their differing indents), so the only real `.cm-line` is
+ // the trailing blank line CodeMirror renders after them -- line 3.
let cm_line = driver.query(By::Css(".cm-line")).first().await.unwrap();
cm_line.click().await.unwrap();
diff --git a/server/tests/overall_4.rs b/server/tests/overall_4.rs
index 81defa41..404c48d2 100644
--- a/server/tests/overall_4.rs
+++ b/server/tests/overall_4.rs
@@ -33,14 +33,16 @@ use std::{path::PathBuf, time::Duration};
// ### Third-party
use dunce::canonicalize;
+use indoc::formatdoc;
use pretty_assertions::assert_eq;
-use thirtyfour::{By, WebDriver, error::WebDriverError};
+use thirtyfour::{By, Key, WebDriver, error::WebDriverError};
use tokio::time::sleep;
// ### Local
use crate::overall_common::{
- CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, click_element_top_left,
- optional_message, perform_loadfile, select_codechat_iframe,
+ CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, beginning_of_line,
+ click_element_top_left, end_of_line, optional_message, perform_loadfile,
+ select_codechat_iframe,
};
use code_chat_editor::{
processing::{CodeChatForWeb, CodeMirrorDiffable},
@@ -74,9 +76,9 @@ async fn test_xss_core(
let path = canonicalize(test_dir.join("test.md")).unwrap();
let path_str = path.to_str().unwrap().to_string();
let version = 0.0;
- // The malicious payload: an image whose `src` is guaranteed to fail loading,
- // firing the `onerror` handler. If the handler were allowed through, it
- // would log the `XSS` marker to the browser console.
+ // The malicious payload: an image whose `src` is guaranteed to fail
+ // loading, firing the `onerror` handler. If the handler were allowed
+ // through, it would log the `XSS` marker to the browser console.
//
// The `src` is an invalid `data:` URI so the failed load is resolved
// entirely in the browser. (A relative `src` such as `x` would instead make
@@ -103,15 +105,15 @@ async fn test_xss_core(
// during this window.
sleep(Duration::from_millis(500)).await;
- // ### 1. The JavaScript must not have executed.
+ // ### 1\. The JavaScript must not have executed.
//
// Drain the browser console log. chromedriver records page-side `console.*`
// output (and uncaught errors) in the `browser` buffer; if the `onerror`
// handler had run, our `XSS` marker would appear here. Draining via the
// wrapper (rather than `driver.get_log("browser")` directly) both forwards
// each entry to Rust logging and hands the entries back for inspection. Do
- // this right after rendering and before any further server call, which would
- // otherwise drain the buffer first.
+ // this right after rendering and before any further server call, which
+ // would otherwise drain the buffer first.
let entries = codechat_server.poll_log().await;
for entry in &entries {
assert!(
@@ -121,7 +123,7 @@ async fn test_xss_core(
);
}
- // ### 2. The rendered DOM must not contain the malicious handler.
+ // ### 2\. The rendered DOM must not contain the malicious handler.
//
// The doc block should render the image with its `onerror` attribute
// stripped, leaving a harmless `
`.
@@ -137,7 +139,7 @@ async fn test_xss_core(
"Expected a sanitized
tag in the rendered DOM: {rendered}"
);
- // ### 3. Editing the document must write back sanitized source.
+ // ### 3\. Editing the document must write back sanitized source.
//
// Click into the doc block, then type a character. The Client converts the
// (already sanitized) rendered HTML back to source and sends it to the IDE
@@ -181,8 +183,8 @@ async fn test_xss_core(
)
.await;
- // The update must carry contents; pull the source out of the diff and verify
- // the malicious handler is gone.
+ // The update must carry contents; pull the source out of the diff and
+ // verify the malicious handler is gone.
let contents = match &msg.message {
EditorMessageContents::Update(UpdateMessageContents {
contents: Some(contents),
@@ -208,8 +210,8 @@ async fn test_xss_core(
client_id += MESSAGE_ID_INCREMENT;
// Editing a doc block prompts the Server to send the Client a re-translated
- // version of the document; the Client's acknowledgement comes back here as a
- // `Result(Ok)` carrying the Server's ID.
+ // version of the document; the Client's acknowledgement comes back here as
+ // a `Result(Ok)` carrying the Server's ID.
assert_eq!(
codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
EditorMessage {
@@ -240,3 +242,570 @@ async fn test_xss_core(
Ok(())
}
+
+make_test!(
+ test_horizontal_scroll_preserved,
+ test_horizontal_scroll_preserved_core
+);
+
+// Regression test for
+// [#113](https://github.com/bjones1/CodeChat_Editor/issues/113): when the IDE
+// moves the cursor into a doc block containing a line too wide for the Client's
+// viewport, the Client must scroll vertically to bring that line into view
+// without disturbing the horizontal scroll position. Before the fix,
+// CodeMirror's `scrollIntoView` pinned the horizontal scrollbar to its maximum.
+//
+// The test loads a doc block containing a few one-line paragraphs, a fenced
+// code block with a very long, non-wrapping line, then more one-line
+// paragraphs. It scrolls the CodeMirror scroller horizontally to a middle
+// position, then simulates the IDE moving its cursor to each line in the doc
+// block (as arrow-key presses in the IDE would), verifying after each move that
+// the horizontal scroll position hasn't changed.
+async fn test_horizontal_scroll_preserved_core(
+ codechat_server: CodeChatEditorServerLog,
+ driver: WebDriver,
+ test_dir: PathBuf,
+) -> Result<(), WebDriverError> {
+ let path = canonicalize(test_dir.join("test.py")).unwrap();
+ let path_str = path.to_str().unwrap().to_string();
+ let ide_version = 0.0;
+ // A long, non-wrapping line: fenced code blocks render as ``, which
+ // doesn't wrap, so this forces horizontal scrolling.
+ let long_line = "x".repeat(500);
+ let orig_text = formatdoc!(
+ "
+ # 1
+ #
+ # 2
+ #
+ # ```
+ # {long_line}
+ # ```
+ #
+ # 8
+ #
+ # 9
+ "
+ );
+ perform_loadfile(
+ &codechat_server,
+ &test_dir,
+ "test.py",
+ Some((orig_text, ide_version)),
+ false,
+ 6.0,
+ )
+ .await;
+
+ // Target the iframe containing the Client.
+ select_codechat_iframe(&driver).await;
+
+ // Scroll the CodeMirror scroller horizontally to a middle position (not
+ // fully left or fully right).
+ let scroller_css = ".CodeChat-CodeMirror .cm-scroller";
+ driver
+ .execute(
+ &format!("document.querySelector('{scroller_css}').scrollLeft = 200;"),
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ let get_scroll_left = format!("return document.querySelector('{scroller_css}').scrollLeft;");
+ let scroll_left_before: f64 = driver
+ .execute(&get_scroll_left, Vec::new())
+ .await
+ .unwrap()
+ .convert()
+ .unwrap();
+ assert!(
+ scroll_left_before > 0.0,
+ "Failed to scroll the CodeMirror scroller horizontally before the test began."
+ );
+
+ // Simulate the IDE moving its cursor to each line of the doc block, as
+ // arrow-key presses in the IDE would produce. Check every line, including
+ // the fenced-code-block lines.
+ for line in 1..=11u32 {
+ let ide_id = codechat_server
+ .send_message_update_plain(path_str.clone(), None, Some(line), Some(line.into()))
+ .await
+ .unwrap();
+ // The Client acknowledges the Update with a Result(Ok).
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: ide_id,
+ message: EditorMessageContents::Result(Ok(ResultOkTypes::Void))
+ }
+ );
+
+ let scroll_left_after: f64 = driver
+ .execute(&get_scroll_left, Vec::new())
+ .await
+ .unwrap()
+ .convert()
+ .unwrap();
+ assert_eq!(
+ scroll_left_after, scroll_left_before,
+ "Horizontal scroll changed after moving the cursor to line {line}."
+ );
+ }
+
+ assert_no_more_messages(&codechat_server).await;
+
+ Ok(())
+}
+
+make_test!(test_arrow_key_navigation, test_arrow_key_navigation_core);
+
+// Regression test replacing the old Client-only unit test (which drove
+// `docBlockNavKeymap` directly by calling `runScopeHandlers` on a bare
+// CodeMirror instance, with hand-built `doc`/`doc_blocks` data). That approach
+// never reflected real behavior: its synthetic `doc` string collapsed a code
+// line's own trailing newline with the following doc block's placeholder
+// newline (e.g. `"a\nb\n\nc"`, where offset 3 -- "b"'s own newline -- doubled
+// as doc block 1's placeholder). The real Server never produces this shape:
+// it always appends each doc block's placeholder newline(s) *in addition to*
+// the preceding code block's own trailing newline (see the comment above
+// `source.push_str(&"\n".repeat(doc_block.lines))` in
+// `processing.rs::source_to_codechat_for_web`).
+//
+// Against a real document, that one-character difference used to matter:
+// `docBlockNavKeymap`'s `ArrowDown` handler computed
+// `search_pos = lineAt(main.head).to`, which lands on the code line's own
+// newline -- one position *before* where the doc block actually starts -- so
+// the lookup never matched, the keymap reported the key unhandled, and
+// CodeMirror's default `ArrowDown` jumped straight over the (atomic) doc block
+// widgets to the next real code line instead. This was a real, reproducible
+// off-by-one bug the old synthetic test could never have caught; it's now
+// fixed by using `lineAt(main.head).to + 1`, matching the `main.head + 1`
+// already used by the analogous `ArrowRight` handler. `ArrowUp`'s
+// `search_pos = lineAt(main.head).from` never had this problem, since a doc
+// block's placeholder newline(s) sit immediately before the following code
+// line with nothing in between.
+//
+// Fixing the off-by-one also exposed a second, related bug: both handlers had
+// a "chained navigation" branch that checked whether `main.head` already sat
+// at a doc block boundary, to decide whether this was a continued chain
+// through consecutive doc blocks rather than a fresh entry from a code line.
+// But `main.head` sitting at a doc block's boundary is exactly what a fresh
+// arrival at the neighboring code line's edge looks like too (a doc block's
+// `to` is numerically identical to the following code line's `from`), so the
+// branch misfired on fresh entries, treating them as chained and skipping
+// straight past the intended doc block. In practice this branch was
+// unreachable for its intended purpose anyway: chaining between two
+// consecutive, already-focused doc blocks happens entirely outside CodeMirror
+// (via the browser's native contenteditable caret handling and
+// `DocBlockPlugin`'s `focusin` promotion -- see the comment further below),
+// so by the time a second consecutive doc-block-entering keypress could
+// occur, CodeMirror would no longer even have focus for `docBlockNavKeymap`
+// to run. Both handlers now just look at the fixed boundary position with no
+// "chained" special case.
+//
+// This test drives real keyboard input through WebDriver, so each
+// `ArrowDown`/`ArrowUp` goes wherever the browser's actual focus is -- exactly
+// as a user's keystrokes would -- rather than assuming CodeMirror stays
+// focused across every keypress the way the old test did. It uses the same
+// document shape as the old test: a code block (`a`, `b`), two consecutive
+// one-line doc blocks with different indents (so they remain separate blocks
+// -- see the merge rule in `lexer.rs`), then another code block (`c`).
+//
+// After each keypress, the test waits for the autosave timer to fire and
+// checks the `cursor_position` the Client reports back to the IDE. A doc
+// block's cursor is computed from `document.activeElement` client-side (see
+// `set_CodeMirror_positions` in `CodeMirror-integration.mts`) and sent to the
+// Server as a `DomLocation`, which the Server then translates into the
+// doc block's source line number before forwarding to the IDE (see the
+// comment on `CursorPosition::DomLocation` in `webserver.rs`) -- so a `Line`
+// value naming a doc block's line is proof that real DOM focus, not just
+// CodeMirror's internal selection, moved into that block.
+async fn test_arrow_key_navigation_core(
+ codechat_server: CodeChatEditorServerLog,
+ driver: WebDriver,
+ test_dir: PathBuf,
+) -> Result<(), WebDriverError> {
+ let path = canonicalize(test_dir.join("test.py")).unwrap();
+ let path_str = path.to_str().unwrap().to_string();
+ let ide_version = 0.0;
+ // Source lines, and the CodeMirror line each becomes on the Client:
+ // "a" -> 1, "b" -> 2, "# 3" (doc block 1, indent "") -> 3,
+ // " # 4" (doc block 2, indent " ") -> 4, "c" -> 5. The differing indent
+ // keeps the two doc blocks separate instead of merging into one.
+ let orig_text = "a\nb\n# 3\n # 4\nc\n".to_string();
+ perform_loadfile(
+ &codechat_server,
+ &test_dir,
+ "test.py",
+ Some((orig_text, ide_version)),
+ false,
+ 6.0,
+ )
+ .await;
+
+ // Target the iframe containing the Client.
+ select_codechat_iframe(&driver).await;
+
+ let mut client_id = INITIAL_CLIENT_MESSAGE_ID;
+
+ // Wait for the autosave timer to report the current cursor position, and
+ // check it against the expected code line.
+ async fn assert_cursor_line(
+ codechat_server: &CodeChatEditorServerLog,
+ client_id: &mut f64,
+ path_str: &str,
+ line: u32,
+ ) {
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: *client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.to_string(),
+ cursor_position: Some(CursorPosition::Line(line)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(*client_id, None).await.unwrap();
+ *client_id += MESSAGE_ID_INCREMENT;
+ }
+
+ // ### `ArrowDown` from a code line enters the doc block below it.
+ //
+ // Click near the start of line "b" (the last line of the top code
+ // block), then move to its end with `End`, which is the boundary
+ // `docBlockNavKeymap`'s `ArrowDown` handler looks for.
+ let code_lines = driver
+ .find_all(By::Css(".CodeChat-CodeMirror .cm-line"))
+ .await
+ .unwrap();
+ click_element_top_left(&driver, &code_lines[1])
+ .await
+ .unwrap();
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 2).await;
+ end_of_line(&code_lines[1], "").await.unwrap();
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 2).await;
+
+ // With the off-by-one fixed, this moves focus into the first doc block
+ // ("# 3", line 3) rather than skipping both (atomic) doc block widgets to
+ // land on "c" (line 5).
+ driver
+ .action_chain()
+ .send_keys(Key::Down)
+ .perform()
+ .await
+ .unwrap();
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 3).await;
+
+ // ### Chained navigation between two consecutive doc blocks works too.
+ //
+ // Focus is now genuinely in the first doc block's `.CodeChat-doc-contents`
+ // div (promoted to TinyMCE), outside CodeMirror and thus outside
+ // `docBlockNavKeymap`. Even so, a further `ArrowDown` (with the caret
+ // already at the very end of that block's content) moves focus into the
+ // second, following doc block -- the browser's default caret-boundary
+ // handling for the contenteditable region falls through to the adjacent
+ // `.CodeChat-doc-contents` div, which then goes through the same
+ // `focusin` promotion as any other doc block. Doc block 2 (" # 4")
+ // translates to line 4.
+ driver
+ .action_chain()
+ .send_keys(Key::Down)
+ .perform()
+ .await
+ .unwrap();
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 4).await;
+
+ // ### `ArrowDown` from the last doc block exits back to code.
+ //
+ // A doc block's contents div (once promoted, living inside TinyMCE's own
+ // iframe document) is a contenteditable region entirely separate from
+ // CodeMirror's, so unlike the doc-block-to-doc-block case above (which
+ // works because both blocks are DOM siblings the browser's default
+ // caret-boundary handling walks between), nothing hands focus back to
+ // CodeMirror when there's no further doc block to chain into via that
+ // same mechanism. In practice, though, the browser's caret-boundary walk
+ // continues past doc block 2's contents into its `.CodeChat-doc` sibling
+ // structure and on to the next code line "c" (line 5) once the indent
+ // div is no longer permanently `contenteditable` (see the `mousedown`/
+ // `focusout` handlers in `DocBlockPlugin`, which toggle it instead).
+ driver
+ .action_chain()
+ .send_keys(Key::Down)
+ .perform()
+ .await
+ .unwrap();
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 5).await;
+
+ // ### `ArrowUp` from a code line correctly enters the doc block above it.
+ //
+ // `ArrowUp`'s boundary math has no off-by-one (a doc block's placeholder
+ // newline(s) sit immediately before the following code line), so this
+ // direction has always worked. Click directly on code line "c" to give
+ // CodeMirror real focus there (the doc block gap above left focus stuck
+ // in doc block 2's TinyMCE instance), then press `Home` so the cursor
+ // sits at the exact line start `docBlockNavKeymap`'s `ArrowUp` handler
+ // looks for.
+ let c_line = driver
+ .find(By::XPath("//*[contains(@class, 'cm-line')][text()='c']"))
+ .await
+ .unwrap();
+ click_element_top_left(&driver, &c_line).await.unwrap();
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 5).await;
+ beginning_of_line(&c_line, "").await.unwrap();
+ sleep(Duration::from_millis(400)).await;
+ while let Some(msg) = codechat_server
+ .get_message_timeout(Duration::from_millis(100))
+ .await
+ {
+ assert_eq!(msg.id, client_id);
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+ }
+
+ driver
+ .action_chain()
+ .send_keys(Key::Up)
+ .perform()
+ .await
+ .unwrap();
+ // The Client reports the doc block's cursor as a `DomLocation` (a DOM
+ // coordinate), but the Server translates that into a plain `Line` before
+ // forwarding to the IDE -- `DomLocation` is a Client/Server-only detail
+ // (see the comment on `CursorPosition::DomLocation` in `webserver.rs`).
+ // Doc block 2 (" # 4") translates to line 4.
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 4).await;
+
+ // ### Chaining `ArrowUp` between two consecutive doc blocks overshoots
+ // straight to the code above, skipping doc block 1.
+ //
+ // Focus is now genuinely in the second doc block's
+ // `.CodeChat-doc-contents` div (promoted to TinyMCE), outside CodeMirror
+ // and thus outside `docBlockNavKeymap`, with the caret at the very end of
+ // that block's content ("entering from below" lands the caret at the
+ // end; see `DocBlockPlugin`'s `focusin` handler). Each doc block's
+ // wrapper (`.CodeChat-doc`) is no longer permanently `contenteditable`
+ // on its indent (see the `mousedown`/`focusout` handlers in
+ // `DocBlockPlugin`), so the browser's native caret-boundary walk now
+ // carries `ArrowUp` all the way from doc block 2's contents, through doc
+ // block 1's (very short, single-character) contents, and out the other
+ // side into code line "b" (line 2) in a single keypress -- reported as a
+ // plain `Line`, not a `DomLocation`, confirming focus landed in
+ // CodeMirror rather than a doc block's DOM.
+ driver
+ .action_chain()
+ .send_keys(Key::Up)
+ .perform()
+ .await
+ .unwrap();
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 2).await;
+
+ assert_no_more_messages(&codechat_server).await;
+
+ Ok(())
+}
+
+make_test!(
+ test_arrow_key_navigation_multiline_doc_block,
+ test_arrow_key_navigation_multiline_doc_block_core
+);
+
+// Regression test for a manually-observed bug: moving the cursor from a code
+// line to a *multi-line* doc block places the cursor at the doc block's first
+// line rather than its last line. `test_arrow_key_navigation_core` above only
+// exercises single-line doc blocks (two separate one-line doc blocks, in
+// fact), which isn't enough to catch this -- entering a one-line doc block,
+// its first line and its last line are the same line, so an off-by-one in
+// "first vs. last" can't show up there.
+//
+// This test uses six consecutive comment lines sharing the same indent
+// ("# 3", "#", then four "# " lines), which the lexer merges
+// into a *single* six-line doc block (see the check `last_doc_block.indent ==
+// indent && last_doc_block.delimiter == delimiter` in `lexer.rs`, which
+// appends each comment's contents to the previous one rather than starting a
+// new doc block). The blank `#` line forces the Markdown contents to render
+// as two separate paragraphs rather than one line-wrapped paragraph (matching
+// the `// One`, `//`, `// Two` pattern used by the analogous Rust unit test in
+// `processing/tests.rs`); without it, CommonMark would join "3" and the
+// second paragraph into a single visual line, unable to expose a
+// first-vs-last-line bug. The second paragraph's four lines are themselves
+// pre-wrapped (each already at the Server's own word-wrap width for this doc
+// block -- see the `wrapped_line` comment below), so CommonMark's soft-wrap
+// rule joins them into one long visual paragraph *without* the Server's
+// HTML-to-Markdown caret-location logic (`doc_block_html_to_markdown` in
+// `processing.rs`) needing to invent any new line breaks when it re-wraps
+// them to locate the caret. That matters: an *unwrapped* single long source
+// line for this paragraph also fails this test, but for a different reason
+// than a beginning/end caret mix-up -- the re-wrap invents extra line breaks
+// not present in the actual CodeMirror source, inflating the reported line
+// number past the end of the document entirely. Pre-wrapping the source
+// avoids that confound, isolating this test to the beginning-vs-end caret
+// question alone; both the line-number check and a direct DOM caret-position
+// check below pass under these conditions, indicating that particular defect
+// doesn't reproduce here. That doc block spans CodeMirror lines 3-8; the
+// following code line "c" is line 9.
+async fn test_arrow_key_navigation_multiline_doc_block_core(
+ codechat_server: CodeChatEditorServerLog,
+ driver: WebDriver,
+ test_dir: PathBuf,
+) -> Result<(), WebDriverError> {
+ let path = canonicalize(test_dir.join("test.py")).unwrap();
+ let path_str = path.to_str().unwrap().to_string();
+ let ide_version = 0.0;
+ // A paragraph line, pre-wrapped at the same width the Server's own word
+ // wrap (`WORD_WRAP_COLUMN` minus this doc block's delimiter-plus-space
+ // width, in `processing.rs`) produces for this doc block's indent ("")
+ // and delimiter ("#") -- confirmed by feeding this exact paragraph
+ // through `doc_block_html_to_markdown` directly, which wraps it into four
+ // lines of "four" repeated 15 times each (74 characters). Repeating that
+ // already-wrapped line four times below reproduces the Server's own wrap
+ // points exactly, so its HTML-to-Markdown re-wrap (done to locate the
+ // caret) doesn't invent any new line breaks.
+ let wrapped_line = std::iter::repeat_n("four", 15)
+ .collect::>()
+ .join(" ");
+ // Source lines, and the CodeMirror line each becomes on the Client:
+ // "a" -> 1, "b" -> 2, "# 3" + "#" + four "# " lines (one
+ // merged six-line doc block, indent "") -> 3-8, "c" -> 9.
+ let orig_text = format!(
+ "a\nb\n# 3\n#\n# {wrapped_line}\n# {wrapped_line}\n# {wrapped_line}\n# {wrapped_line}\nc\n"
+ );
+ perform_loadfile(
+ &codechat_server,
+ &test_dir,
+ "test.py",
+ Some((orig_text, ide_version)),
+ false,
+ 6.0,
+ )
+ .await;
+
+ // Target the iframe containing the Client.
+ select_codechat_iframe(&driver).await;
+
+ let mut client_id = INITIAL_CLIENT_MESSAGE_ID;
+
+ // Wait for the autosave timer to report the current cursor position, and
+ // check it against the expected code line.
+ async fn assert_cursor_line(
+ codechat_server: &CodeChatEditorServerLog,
+ client_id: &mut f64,
+ path_str: &str,
+ line: u32,
+ ) {
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: *client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.to_string(),
+ cursor_position: Some(CursorPosition::Line(line)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(*client_id, None).await.unwrap();
+ *client_id += MESSAGE_ID_INCREMENT;
+ }
+
+ // Click directly on code line "c" -- the line immediately following the
+ // multi-line doc block -- to give CodeMirror real focus there, then press
+ // `Home` so the cursor sits at the exact line start `docBlockNavKeymap`'s
+ // `ArrowUp` handler looks for.
+ //
+ // Confirm the click genuinely lands on code line "c" (i.e. `Line(9)`),
+ // not inside the preceding doc block.
+ let c_line = driver
+ .find(By::XPath("//*[contains(@class, 'cm-line')][text()='c']"))
+ .await
+ .unwrap();
+ c_line.click().await.unwrap();
+ end_of_line(&c_line, "").await.unwrap();
+ assert_cursor_line(&codechat_server, &mut client_id, &path_str, 9).await;
+
+ // `ArrowUp` from code line "c" should enter the doc block above it with
+ // the cursor at the block's *last* line (8), matching the "entering from
+ // below lands at the end" rule documented on `docBlockNavKeymap` and
+ // `DocBlockPlugin`'s `focusin` handler.
+ driver
+ .action_chain()
+ .send_keys(Key::Up)
+ .perform()
+ .await
+ .unwrap();
+ let msg = optional_message(
+ &codechat_server,
+ &mut client_id,
+ EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(8)),
+ scroll_position: None,
+ is_re_translation: false,
+ contents: None,
+ }),
+ )
+ .await;
+ assert_eq!(
+ msg,
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.to_string(),
+ cursor_position: Some(CursorPosition::Line(8)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ //client_id += MESSAGE_ID_INCREMENT;
+
+ // `Line(8)` only proves the caret is somewhere on the paragraph's *last*
+ // source line -- it can't distinguish that line's start from its end.
+ // Independently confirm the DOM caret placement itself: per the
+ // "entering from below lands at the end" rule (see `DocBlockPlugin`'s
+ // `focusin` handler in `CodeMirror-integration.mts`, which does
+ // `range.selectNodeContents(contents); range.collapse(!at_end)`), the
+ // caret should sit at the very end of the doc block's text -- after the
+ // last "four" -- not at its start.
+ let is_caret_at_end: bool = driver
+ .execute(
+ "const contents = document.activeElement.closest('.CodeChat-doc-contents');
+ if (!contents) return false;
+ const sel = window.getSelection();
+ if (sel.rangeCount === 0) return false;
+ // Walk to the last text node under `contents` (the true end of
+ // its rendered content), rather than comparing against an
+ // element-boundary point -- an (element, childNodes.length)
+ // point always sorts after any (textNode, offset) point inside
+ // that last child, even when the text offset is the text node's
+ // own final position, which would produce false negatives here.
+ let last_text_node = contents;
+ while (last_text_node.lastChild) {
+ last_text_node = last_text_node.lastChild;
+ }
+ return (
+ sel.anchorNode === last_text_node &&
+ sel.anchorOffset === last_text_node.textContent.length
+ );",
+ Vec::new(),
+ )
+ .await
+ .unwrap()
+ .convert()
+ .unwrap();
+ assert!(
+ is_caret_at_end,
+ "ArrowUp from code line \"c\" should land the caret at the end of the \
+ multi-line doc block's last (word-wrapped) paragraph, not at its start."
+ );
+
+ assert_no_more_messages(&codechat_server).await;
+
+ Ok(())
+}
diff --git a/server/tests/overall_5.rs b/server/tests/overall_5.rs
new file mode 100644
index 00000000..8c7583d5
--- /dev/null
+++ b/server/tests/overall_5.rs
@@ -0,0 +1,272 @@
+// Copyright (C) 2025 Bryan A. Jones.
+//
+// This file is part of the CodeChat Editor. The CodeChat Editor is free
+// software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either
+// version 3 of the License, or (at your option) any later version.
+//
+// The CodeChat Editor is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// the CodeChat Editor. If not, see
+// [http://www.gnu.org/licenses](http://www.gnu.org/licenses).
+/// `overall_5.rs` - test the overall system
+/// ========================================
+///
+/// These are functional tests of the overall system, performed by attaching a
+/// testing IDE to generate commands then observe results, along with a browser
+/// tester.
+///
+/// To run this test, execute `cargo test --test overall_5 `
+/// in the `server/` directory.
+// Modules
+// -------
+mod overall_common;
+
+// Imports
+// -------
+//
+// ### Standard library
+use std::path::PathBuf;
+
+// ### Third-party
+use dunce::canonicalize;
+use pretty_assertions::assert_eq;
+use thirtyfour::{By, Key, WebDriver, error::WebDriverError};
+
+// ### Local
+use crate::overall_common::{
+ CodeChatEditorServerLog, TIMEOUT, assert_no_more_messages, beginning_of_line, end_of_line,
+ perform_loadfile, select_codechat_iframe,
+};
+use code_chat_editor::webserver::{
+ CursorPosition, EditorMessage, EditorMessageContents, INITIAL_CLIENT_MESSAGE_ID,
+ MESSAGE_ID_INCREMENT, UpdateMessageContents,
+};
+use test_utils::prep_test_dir;
+
+// Tests
+// -----
+make_test!(
+ test_cursor_home_from_code_after_doc_block,
+ test_cursor_home_from_code_after_doc_block_core
+);
+
+// Regression test to ensure left arrow allows placing the cursor at the
+// beginning of a code block, then moves back to the end of the preceding doc
+// block on another left arrow press. The preceding doc block here spans two
+// source lines, to catch a manually-observed bug (currently failing) where
+// that second left-arrow press lands the cursor at the doc block's
+// *beginning* instead of its *end*. Also checks (currently failing) that
+// pressing Home at the end of the code block keeps the cursor on that line
+// rather than moving up into the preceding doc block, and that pressing Home
+// a second time (a no-op, since the cursor is already at the start of the
+// line) also keeps the cursor on the current line.
+async fn test_cursor_home_from_code_after_doc_block_core(
+ codechat_server: CodeChatEditorServerLog,
+ driver: WebDriver,
+ test_dir: PathBuf,
+) -> Result<(), WebDriverError> {
+ let path = canonicalize(test_dir.join("test.py")).unwrap();
+ let path_str = path.to_str().unwrap().to_string();
+ let ide_version = 0.0;
+ let orig_text = "# a
\n# b\ncc\n".to_string();
+ perform_loadfile(
+ &codechat_server,
+ &test_dir,
+ "test.py",
+ Some((orig_text, ide_version)),
+ false,
+ 6.0,
+ )
+ .await;
+
+ // Target the iframe containing the Client.
+ select_codechat_iframe(&driver).await;
+
+ let mut client_id = INITIAL_CLIENT_MESSAGE_ID;
+
+ // Click on the two-character code block ("cc"), which focuses CodeMirror
+ // and reports the cursor at line 3. The click is in the middle of the element, which places the cursor at the end of the line (given that the width of the screen is much larger than the width of a two-character line.)
+ let code_line = driver
+ .find(By::XPath("//*[contains(@class, 'cm-line')][text()='cc']"))
+ .await
+ .unwrap();
+ code_line.click().await.unwrap();
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(3)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // The cursor is at the end of the two "c"s. The first `Left` press should
+ // simply move the cursor to the middle of the two "c"s, staying on the current
+ // line rather than jumping into the preceding doc block.
+ code_line.send_keys(Key::Left).await.unwrap();
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(3)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // The cursor is at middle the two "c"s. The next `Left` press should
+ // move the cursor to the beginning of the two "c"s, staying on the current
+ // line rather than jumping into the preceding doc block.
+ code_line.send_keys(Key::Left).await.unwrap();
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(3)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // The cursor is now at the start of code line "cc". A final `Left` press
+ // should enter the preceding two-line doc block, with the caret landing
+ // at the block's *end* (per the "entering from below lands at the end"
+ // rule documented on `docBlockNavKeymap`'s `ArrowLeft` handler), not its
+ // start.
+ code_line.send_keys(Key::Left).await.unwrap();
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(2)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // `Line(2)` only proves the caret is somewhere on the doc block's last
+ // source line -- it can't distinguish that line's start from its end.
+ // Independently confirm the DOM caret placement itself, mirroring the
+ // check in `test_arrow_key_navigation_multiline_doc_block_core`
+ // (`overall_4.rs`): the caret should sit at the very end of the doc
+ // block's text -- after "b" -- not at its start.
+ let is_caret_at_end: bool = driver
+ .execute(
+ "const contents = document.activeElement.closest('.CodeChat-doc-contents');
+ if (!contents) return false;
+ const sel = window.getSelection();
+ if (sel.rangeCount === 0) return false;
+ let last_text_node = contents;
+ while (last_text_node.lastChild) {
+ last_text_node = last_text_node.lastChild;
+ }
+ return (
+ sel.anchorNode === last_text_node &&
+ sel.anchorOffset === last_text_node.textContent.length
+ );",
+ Vec::new(),
+ )
+ .await
+ .unwrap()
+ .convert()
+ .unwrap();
+ assert!(
+ is_caret_at_end,
+ "ArrowLeft from code line \"cc\" should land the caret at the end of the \
+ two-line doc block, not at its start."
+ );
+
+ // Move back into the code block for the remaining `Home` checks below.
+ code_line.click().await.unwrap();
+ end_of_line(&code_line, "").await.unwrap();
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(3)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // The cursor is already at the end of the line (from `end_of_line` above,
+ // line 214), so press `Home` via the `beginning_of_line` helper directly,
+ // to check for a regression: the cursor should stay on the current line
+ // rather than jumping up into the preceding doc block.
+ beginning_of_line(&code_line, "").await.unwrap();
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(3)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // Press `Home` a second time. The cursor is already at the beginning of
+ // the line, so this should be a no-op that keeps the cursor on the
+ // current line -- not a jump up into the preceding doc block.
+ beginning_of_line(&code_line, "").await.unwrap();
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: path_str.clone(),
+ cursor_position: Some(CursorPosition::Line(3)),
+ scroll_position: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ //client_id += MESSAGE_ID_INCREMENT;
+
+ assert_no_more_messages(&codechat_server).await;
+
+ Ok(())
+}
diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs
index 3b09a9b3..38be0205 100644
--- a/server/tests/overall_common/mod.rs
+++ b/server/tests/overall_common/mod.rs
@@ -54,7 +54,7 @@ use futures::FutureExt;
use pretty_assertions::assert_eq;
use thirtyfour::{
BrowserLogEntry, By, ChromiumLikeCapabilities, DesiredCapabilities, Key, LoggingPrefsLogLevel,
- WebDriver, WebElement, error::WebDriverError,
+ TypingData, WebDriver, WebElement, error::WebDriverError,
};
use tracing::{debug, error, info, warn};
use tracing_log::LogTracer;
@@ -272,14 +272,22 @@ pub async fn harness<
// key to go to the end of the line...but it's not the end of the full line
// on a narrow screen.
caps.add_arg("--window-size=1920,768")?;
- //caps.add_arg("--auto-open-devtools-for-tabs")?;
// Tell chromedriver to capture page-side `console.*` output and uncaught
// JavaScript errors in the `browser` log buffer, which we drain below and
// forward to Rust logging. Without this capability the `/log` endpoint
// returns nothing regardless of what the page does.
caps.set_browser_log_level(LoggingPrefsLogLevel::All)?;
- // Comment this out to debug test failures.
+
+ // Debug support:
+ //
+ // Comment/uncomment these out to debug test failures.
caps.add_arg("--headless")?;
+ //caps.add_arg("--auto-open-devtools-for-tabs")?;
+ // Insert the code in a test to pause it for manual inspection.
+ //use std::time::Duration;
+ //use tokio::time::sleep;
+ //sleep(Duration::from_hours(1)).await;
+
// On Ubuntu CI, avoid failures, probably due to running Chrome as root.
#[cfg(target_os = "linux")]
if env::var("CI") == Ok("true".to_string()) {
@@ -395,6 +403,23 @@ macro_rules! make_test {
$crate::overall_common::harness($test_core_name, prep_test_dir!()).await
}
};
+
+ // Same as above, but for a test that's currently known to fail (a
+ // regression test pinning down an unfixed bug). `harness` converts a
+ // panic into a `Result::Err` (see its `catch_unwind` use, needed to shut
+ // the WebDriver down cleanly), so the failure never becomes a live
+ // unwind -- meaning `#[should_panic]` can't detect it. `#[ignore]` is
+ // the alternative: the test is skipped by default (so the suite stays
+ // green) but still compiles, and can be run explicitly with `cargo test
+ // -- --ignored` to check whether the bug has been fixed yet.
+ ($test_name: ident, $test_core_name: ident, ignore = $reason: literal) => {
+ #[tokio::test]
+ #[tracing::instrument]
+ #[ignore = $reason]
+ async fn $test_name() -> Result<(), Box> {
+ $crate::overall_common::harness($test_core_name, prep_test_dir!()).await
+ }
+ };
}
// Given an `Update` message with contents, get the version.
@@ -478,6 +503,106 @@ pub async fn goto_line(
Ok(())
}
+// Cursor-navigation helpers
+// -------------------------
+//
+// On macOS, key combinations that are supposed to jump to the beginning/end of
+// a document (`Ctrl+Home`, `Ctrl+End`, `Cmd+Up`, `Cmd+Down`, ...) are handled
+// by macOS's native Cocoa/WebKit text-editing bridge rather than by pure
+// JavaScript `keydown`/`keyup` DOM events. ChromeDriver's `send_keys` only
+// injects DOM-level key events, so on macOS these shortcuts are silently
+// swallowed before they ever produce the OS-level cursor jump. This is a
+// longstanding WebDriver/Selenium limitation, not a bug in this project --
+// see [Command key modifier doesn't work on Mac
+// OS](https://github.com/SeleniumHQ/selenium/issues/1290).
+//
+// `Ctrl+Home`/`Ctrl+End` do work via `send_keys` on Windows/Linux for a plain
+// CodeMirror line, but empirically do *not* reliably reach the true
+// beginning/end inside a TinyMCE `contenteditable` doc block (observed
+// landing partway through the block instead of at the first/last paragraph,
+// even on Windows) -- presumably TinyMCE's own keyboard-shortcut handling
+// intercepts or only partially handles them. So rather than branch by OS,
+// [`beginning_of_document`] and [`end_of_document`] always use the
+// repeated-arrow-key approach below, which works uniformly on every platform
+// and in both CodeMirror and TinyMCE elements.
+//
+// Each helper accepts `keys_after`, sent in the same `send_keys` call as the
+// navigation keys (rather than a separate `send_keys` call afterward): each
+// `send_keys` call can produce its own debounced cursor-update message, so
+// splitting one logical action into two calls can surface an extra, unwanted
+// intermediate update.
+
+// Move the cursor to the beginning of the current line, then send `keys_after`
+// (which may be empty). Plain `Home` (no modifier) is handled by the browser
+// itself on every platform, so no OS-specific workaround is needed here.
+#[allow(dead_code)]
+#[tracing::instrument(skip(element))]
+pub async fn beginning_of_line(
+ element: &WebElement,
+ keys_after: impl Into + std::fmt::Debug,
+) -> Result<(), WebDriverError> {
+ element.send_keys(Key::Home + keys_after).await
+}
+
+// Move the cursor to the end of the current line, then send `keys_after`
+// (which may be empty). Plain `End` (no modifier) is handled by the browser
+// itself on every platform, so no OS-specific workaround is needed here.
+#[allow(dead_code)]
+#[tracing::instrument(skip(element))]
+pub async fn end_of_line(
+ element: &WebElement,
+ keys_after: impl Into + std::fmt::Debug,
+) -> Result<(), WebDriverError> {
+ element.send_keys(Key::End + keys_after).await
+}
+
+// Move the cursor to the beginning of the document, then send `keys_after`
+// (which may be empty). See the module-level comment above for why this
+// presses `Up` enough times to reach line 1 from anywhere within a test
+// fixture's (small) document, rather than using `Ctrl+Home`/`Cmd+Up`.
+#[allow(dead_code)]
+#[tracing::instrument(skip(element))]
+pub async fn beginning_of_document(
+ element: &WebElement,
+ keys_after: impl Into + std::fmt::Debug,
+) -> Result<(), WebDriverError> {
+ // Test fixtures used by this suite are well under this many lines;
+ // repeating `Up` past the first line is a no-op, so an overshoot is
+ // harmless. Sent as a single `send_keys` call, along with `keys_after`,
+ // so this produces one cursor update rather than one per repeated key.
+ let keys: TypingData = repeated_key(Key::Up, MAX_TEST_DOCUMENT_LINES) + keys_after;
+ element.send_keys(keys).await
+}
+
+// Move the cursor to the end of the document, then send `keys_after` (which
+// may be empty). See the module-level comment above for why this presses
+// `Down` enough times to reach the last line from anywhere within a test
+// fixture's (small) document (then `End` to reach the end of that line),
+// rather than using `Ctrl+End`/`Cmd+Down`.
+#[allow(dead_code)]
+#[tracing::instrument(skip(element))]
+pub async fn end_of_document(
+ element: &WebElement,
+ keys_after: impl Into + std::fmt::Debug,
+) -> Result<(), WebDriverError> {
+ let keys: TypingData = repeated_key(Key::Down, MAX_TEST_DOCUMENT_LINES) + Key::End + keys_after;
+ element.send_keys(keys).await
+}
+
+// Build a `TypingData` consisting of `key` repeated `count` times, for the
+// macOS repeated-arrow-key workaround in [`beginning_of_document`] and
+// [`end_of_document`].
+fn repeated_key(key: Key, count: u32) -> TypingData {
+ std::iter::repeat_n(key.value(), count as usize)
+ .collect::()
+ .into()
+}
+
+// An upper bound on the number of lines in any document used by this test
+// suite's fixtures, used by the macOS `Up`/`Down`-repeating workaround in
+// [`beginning_of_document`] and [`end_of_document`] above.
+const MAX_TEST_DOCUMENT_LINES: u32 = 100;
+
pub async fn perform_loadfile(
codechat_server: &CodeChatEditorServerLog,
test_dir: &Path,
diff --git a/test_utils/Cargo.lock b/test_utils/Cargo.lock
index 32b8f1ab..af8324f3 100644
--- a/test_utils/Cargo.lock
+++ b/test_utils/Cargo.lock
@@ -13,18 +13,17 @@ dependencies = [
[[package]]
name = "anstyle"
-version = "1.0.13"
+version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "assert_fs"
-version = "1.1.3"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9"
+checksum = "6ecf5c70ca07b7f80220bce936f0556a960ca6fb00fc2bd4125b5e581b218137"
dependencies = [
"anstyle",
- "doc-comment",
"globwalk",
"predicates",
"predicates-core",
@@ -34,37 +33,35 @@ dependencies = [
[[package]]
name = "assertables"
-version = "9.8.3"
+version = "10.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbada39b42413d4db3d9460f6e791702490c40f72924378a1b6fc1a4181188fd"
+checksum = "b3c16d80246a076246d8b525d9f404543e90fe0818636cd89c249c2a4bee3bb1"
+dependencies = [
+ "regex",
+ "walkdir",
+]
[[package]]
name = "bitflags"
-version = "2.10.0"
+version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bstr"
-version = "1.12.1"
+version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
dependencies = [
"memchr",
- "serde",
+ "serde_core",
]
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
[[package]]
name = "crossbeam-deque"
-version = "0.8.6"
+version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
@@ -72,18 +69,18 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
-version = "0.9.18"
+version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
-version = "0.8.21"
+version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "difflib"
@@ -91,12 +88,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
-[[package]]
-name = "doc-comment"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9"
-
[[package]]
name = "errno"
version = "0.3.14"
@@ -109,21 +100,9 @@ dependencies = [
[[package]]
name = "fastrand"
-version = "2.3.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
-
-[[package]]
-name = "getrandom"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi",
- "wasip2",
-]
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "globset"
@@ -151,9 +130,9 @@ dependencies = [
[[package]]
name = "ignore"
-version = "0.4.25"
+version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
+checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f"
dependencies = [
"crossbeam-deque",
"globset",
@@ -167,39 +146,39 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.178"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
-version = "0.11.0"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
-version = "0.4.29"
+version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
-version = "2.7.6"
+version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
-version = "1.21.3"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "predicates"
-version = "3.1.3"
+version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
+checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
dependencies = [
"anstyle",
"difflib",
@@ -208,15 +187,15 @@ dependencies = [
[[package]]
name = "predicates-core"
-version = "1.0.9"
+version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
+checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
[[package]]
name = "predicates-tree"
-version = "1.0.12"
+version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
+checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
dependencies = [
"predicates-core",
"termtree",
@@ -224,33 +203,39 @@ dependencies = [
[[package]]
name = "proc-macro2"
-version = "1.0.103"
+version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
-version = "1.0.42"
+version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
-name = "r-efi"
-version = "5.3.0"
+name = "regex"
+version = "1.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
[[package]]
name = "regex-automata"
-version = "0.4.13"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
@@ -259,15 +244,15 @@ dependencies = [
[[package]]
name = "regex-syntax"
-version = "0.8.8"
+version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustix"
-version = "1.1.2"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
@@ -285,15 +270,6 @@ dependencies = [
"winapi-util",
]
-[[package]]
-name = "serde"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
-dependencies = [
- "serde_core",
-]
-
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -316,9 +292,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "2.0.111"
+version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
@@ -327,12 +303,11 @@ dependencies = [
[[package]]
name = "tempfile"
-version = "3.23.0"
+version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom",
"once_cell",
"rustix",
"windows-sys",
@@ -355,9 +330,9 @@ dependencies = [
[[package]]
name = "unicode-ident"
-version = "1.0.22"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
@@ -369,15 +344,6 @@ dependencies = [
"winapi-util",
]
-[[package]]
-name = "wasip2"
-version = "1.0.1+wasi-0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
-dependencies = [
- "wit-bindgen",
-]
-
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -401,9 +367,3 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
-
-[[package]]
-name = "wit-bindgen"
-version = "0.46.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
diff --git a/test_utils/Cargo.toml b/test_utils/Cargo.toml
index a475d3db..fc6caa7e 100644
--- a/test_utils/Cargo.toml
+++ b/test_utils/Cargo.toml
@@ -5,5 +5,5 @@ edition = "2024"
[dependencies]
assert_fs = "1"
-assertables = "9"
+assertables = "10"
log = "0.4"
diff --git a/toc.md b/toc.md
index d94247be..2d11d4d5 100644
--- a/toc.md
+++ b/toc.md
@@ -80,6 +80,7 @@ Implementation
* [Cargo.toml](extensions/VSCode/Cargo.toml)
* [Developer documentation](extensions/VSCode/developer.md)
* Development tools
+ * [CLAUDE.md](CLAUDE.md)
* Builder
* [builder/Cargo.toml](builder/Cargo.toml)
* [builder/src/main.rs](builder/src/main.rs)